35 Commits

Author SHA1 Message Date
fbb7323a1e ci: add test summary to Teams notification 2026-04-11 15:50:17 +05:30
8955062b6d docs: add CI/CD operations guide
Covers Gitea + Woodpecker + MinIO pipeline setup, Teams
notifications, test report publishing, mirrored repos,
secrets config, troubleshooting, and E2E test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:41:26 +05:30
1e4fa41a97 ci: fix Teams notification — use Adaptive Card with curl
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 15:37:08 +05:30
199176e729 ci: use Teams notification plugin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-04-11 15:34:19 +05:30
5a7c1ae74e ci: add Teams notification with report link
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
2026-04-11 15:28:30 +05:30
ab6bb3424c ci: publish HTML report to MinIO via S3 plugin
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 14:52:06 +05:30
a1a4320f20 ci: revert to working format, no volumes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 14:34:50 +05:30
d71551536d ci: fix pipeline YAML format (use step list syntax) 2026-04-11 14:31:49 +05:30
33cbe61aec ci: publish HTML report to /reports/{pipeline-number} 2026-04-11 14:27:15 +05:30
f6554b95d4 ci: use yarn instead of npm (npm Exit handler bug)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 14:05:24 +05:30
460e422c94 ci: use playwright image directly, skip typecheck for now
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 14:02:03 +05:30
6027280dc2 ci: use node:20 (npm on node:22 crashes in CI)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 13:54:40 +05:30
18a626b8d5 ci: use npm install with public registry (lockfile has verdaccio URLs)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 13:51:19 +05:30
2099584e0f ci: use node:22 full image, add --prefer-offline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 13:48:55 +05:30
d2b04386d1 ci: add Woodpecker pipeline — typecheck + E2E smoke tests
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:59:33 +05:30
cb4894ddc3 feat: Global E2E tests, multi-agent fixes, SIP agent tracing
- 13 Global Hospital smoke tests (CC Agent + Supervisor)
- Auto-unlock agent session in test setup via maint API
- agent-status-toggle sends agentId from localStorage (was missing)
- maint-otp-modal injects agentId from localStorage into all calls
- SIP manager logs agent identity on connect/disconnect/state changes
- seed-data.ts: added CC agent + marketing users, idempotent member
  creation, cleanup phase before seeding
- .gitignore: exclude test-results/ and playwright-report/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:12:22 +05:30
f09250f3ef docs: update developer runbook for EC2, remove duplicate
Rewrote developer-operations-runbook.md to reflect the current EC2
multi-tenant deployment (was VPS-only). Covers SSH key setup, all
containers, accounts, deploy steps, E2E tests, Redis ops, DB access,
and troubleshooting. Removed duplicate runbook.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:06:49 +05:30
1cdb7fe9e7 feat: add E2E smoke tests, architecture docs, and operations runbook
- 27 Playwright E2E tests covering login (3 roles), CC Agent pages
  (call desk, call history, patients, appointments, my performance,
  sidebar, sign-out), and Supervisor pages (all 11 pages + sidebar)
- Tests run against live EC2 at ramaiah.engage.healix360.net
- Last test completes sign-out to release agent session for next run
- Architecture doc with updated Mermaid diagram including telephony
  dispatcher, service discovery, and multi-tenant topology
- Operations runbook with SSH access (VPS + EC2), accounts, container
  reference, deploy steps, Redis ops, and troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:54:20 +05:30
a1598716ee fix: #536 My Performance passes agentId to performance endpoint
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:34:00 +05:30
c4b6f9a438 fix: 5 bug fixes — #533 #531 #529 #527 #547
#533: Remove redundant Call History top header (duplicate TopBar)
#531: Block logout during active call (confirm dialog + UCID check)
#529: Block outbound calls when agent is on Break/Training
#527: Remove updatePatient during appointment creation (was mutating
      shared Patient entity, affecting all past appointments)
#547: SLA rules seeded via API (config issue, not code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:26:55 +05:30
951acf59c5 feat: appointment form uses master data endpoint for clinics, doctors, departments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:31:01 +05:30
8da431a6cd feat: Ramaiah hospital seed script — 195 doctors from website data
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:18:48 +05:30
05de50f796 fix: remove hardcoded clinic list, fetch from platform dynamically
Appointment form clinic dropdown was hardcoded to 3 "Global Hospital"
branches. Replaced with a GraphQL query to { clinics } so each
workspace shows its own clinics. If no clinics are configured, the
dropdown is empty instead of showing wrong data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:46:30 +05:30
0fc9375729 fix: prevent SIP disconnect during active call
disconnectSip() now guards against disconnect when outboundPending
or outboundActive is true. Accepts force=true for intentional
disconnects (logout, page unload, component unmount). Prevents
React re-render cycles from killing the SIP WebSocket mid-dial,
which was causing the call to drop and disposition modal to not appear.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:20:46 +05:30
6a2fc47226 fix: SIP disconnect on callState change + dispose sends agentId
- Fixed useEffect dependency bug: callState in deps caused cleanup
  (disconnectSip) to fire on every state transition, killing SIP
  mid-dial. Now uses useRef for callState in beforeunload handler
  with empty deps array — cleanup only fires on unmount.
- sendBeacon auto-dispose now includes agentId from agent config
- Disposition modal submit now includes agentId from agent config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:49:34 +05:30
fb92da113e fix: setup wizard role guard, Doctor.clinic removal, dial sends agent config
Defect 1: Setup wizard (/setup) now guarded by AdminSetupGuard —
CC agents and other non-admin roles are redirected to / instead of
seeing the setup wizard they can't complete.

Defect 3: Removed all references to Doctor.clinic (relation was
replaced by DoctorVisitSlot entity). Updated queries.ts,
appointments.tsx, transforms.ts, doctors.tsx, appointment-form.tsx.

Defect 6 (frontend side): Dial request now sends agentId and
campaignName from localStorage agent config so the sidecar dials
with the correct per-agent credentials, not global defaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:29:32 +05:30
72012f099c fix: three-layer ACW protection — prevent agent stuck in wrapping-up
Root cause: when an agent refreshes the page during or after a call,
the React state (UCID, callState, disposition modal) is wiped. The
SIP BYE event fires but no component exists to trigger the disposition
modal → no POST to /api/ozonetel/dispose → agent stuck in ACW.

Layer 1 (beforeunload warning):
  Shows browser's native "Leave page?" dialog during active calls.
  Agent can cancel and stay.

Layer 2 (sendBeacon auto-dispose):
  UCID persisted to localStorage when call activates. On page unload,
  navigator.sendBeacon fires /api/ozonetel/dispose with
  CALLBACK_REQUESTED. Guaranteed delivery even during page death.
  Cleared from localStorage when disposition modal submits normally.

Layer 3 lives in helix-engage-server (separate commit).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:31:45 +05:30
f57fbc1f24 feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots
- Setup wizard: 3-pane layout with right-side live previews, resume
  banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
  clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
  workspace members (HelixEngage Manager role), visitingHours replaced
  by doctorVisitSlot entity, clinics seeded, portalUserId linked
  dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
  with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
  wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
  no-email-invites rule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:34 +05:30
efe67dc28b feat(call-desk): lock patient name field behind explicit edit + confirm
Fixes the long-standing bug where the Appointment and Enquiry forms
silently overwrote existing patients' names with whatever happened to
be in the form's patient-name input. Before this change, an agent who
accidentally typed over the pre-filled name (or deliberately typed a
different name while booking on behalf of a relative) would rename
the patient across the entire workspace on save. The corruption
cascaded into past appointments, lead history, the AI summary, and
the Redis caller-resolution cache. This was the root cause of the
"Priya Sharma shows as Satya Sharma" incident on staging.

Root cause: appointment-form.tsx:249-278 and enquiry-form.tsx:107-117
fired updatePatient + updateLead.contactName unconditionally on every
save. Nothing distinguished "stub patient with no name yet" from
"existing patient whose name just needs this appointment booked".

Fix — lock-by-default with explicit unlock:

- src/components/modals/edit-patient-confirm-modal.tsx (new):
  generic reusable confirmation modal for any destructive edit to a
  patient's record. Accepts title/description/confirmLabel with
  sensible defaults so the call-desk forms can pass a name-specific
  description, and any future page that needs a "are you sure you
  want to change this patient field?" confirm can reuse it without
  building its own modal. Styled to match the sign-out confirmation
  in sidebar.tsx — warning circle, primary-destructive confirm button.

- src/components/call-desk/appointment-form.tsx:
  - New state: isNameEditable (default false when leadName is
    non-empty; true for first-time callers with no prior name to
    protect) + editConfirmOpen.
  - Name input renders disabled + shows an Edit button next to it
    when locked.
  - Edit button opens EditPatientConfirmModal. Confirm unlocks the
    field for the rest of the form session.
  - Save logic gates updatePatient / updateLead.contactName behind
    `isNameEditable && trimmedName.length > 0 && trimmedName !==
    initialLeadName`. Empty / same-as-initial values never trigger
    the rename chain, even if the field was unlocked.
  - On a real rename, fires POST /api/lead/:id/enrich to regenerate
    the AI summary against the corrected identity (phone passed in
    the body so the sidecar also invalidates the caller-resolution
    cache). Non-rename saves just invalidate the cache via the
    existing /api/caller/invalidate endpoint so status +
    lastContacted updates propagate.
  - Bundled fix: renamed `leadStatus: 'APPOINTMENT_SET'` →
    `status: 'APPOINTMENT_SET'` and `lastContactedAt` →
    `lastContacted` in the updateLead payload. The old field names
    are rejected by the staging platform schema and were causing the
    "Query failed: Field leadStatus is not defined by type
    LeadUpdateInput" toast on every appointment save.

- src/components/call-desk/enquiry-form.tsx:
  - Same lock + Edit + modal pattern as the appointment form.
  - Added leadName prop (the form previously didn't receive one).
  - Gated updatePatient behind the nameChanged check.
  - Gated lead.contactName in updateLead behind the same check.
  - Hooks the enrich endpoint on rename; cache invalidate otherwise.
  - Status + interestedService + source still update on every save
    (those are genuinely about this enquiry, not identity).

- src/components/call-desk/active-call-card.tsx: passes
  leadName={fullName || null} to EnquiryForm so the form can
  pre-populate + lock by default.

Behavior summary:
- New caller, no prior name: field unlocked, agent types, save runs
  the full chain (correct — this IS the name).
- Existing caller, agent leaves name alone: field locked, Save
  creates appointment/enquiry + updates lead status/lastContacted +
  invalidates cache. Zero risk of patient/lead rename.
- Existing caller, agent clicks Edit, confirms modal, changes name,
  Save: full rename chain runs — updatePatient + updateLead +
  /api/lead/:id/enrich + cache invalidate. The only code path that
  can mutate a linked patient's name, and it requires two explicit
  clicks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:54:22 +05:30
a287a97fe4 feat(onboarding/phase-5): wire real forms into the setup wizard
Replaces the Phase 2 StepPlaceholder with six dedicated wizard step
components, each wrapping the corresponding Phase 3/4 form. The parent
setup-wizard.tsx is now a thin dispatcher that owns shell state +
markSetupStepComplete wiring; each step owns its own data load, form
state, validation, and save action.

- src/components/setup/wizard-step-types.ts — shared
  WizardStepComponentProps shape
- src/components/setup/wizard-step-identity.tsx — minimal brand form
  (hospital name + logo URL) hitting /api/config/theme, links out to
  /branding for full customisation
- src/components/setup/wizard-step-clinics.tsx — ClinicForm + createClinic
  mutation, always presents an empty "add new" form
- src/components/setup/wizard-step-doctors.tsx — DoctorForm with clinic
  dropdown, blocks with an inline warning when no clinics exist yet
- src/components/setup/wizard-step-team.tsx — InviteMemberForm with real
  roles fetched from getRoles, sends invitations via sendInvitations
- src/components/setup/wizard-step-telephony.tsx — loads masked config
  from /api/config/telephony, validates required Ozonetel fields on save
- src/components/setup/wizard-step-ai.tsx — loads AI config, clamps
  temperature 0..2, doesn't auto-advance (last step, admin taps Finish)
- src/pages/setup-wizard.tsx — dispatches to the right step component
  based on activeStep, passes a WizardStepComponentProps bundle

Each step calls onComplete(step) after a successful save, which updates
the shared SetupState so the left-nav badges reflect the new status
immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:54:35 +05:30
a7b2fd7fbe feat(onboarding/phase-4): telephony, AI, widget config CRUD pages
Replaces the three remaining Pattern B placeholder routes
(/settings/telephony, /settings/ai, /settings/widget) with real forms
backed by the sidecar config endpoints introduced in Phase 1. Each page
loads the current config on mount, round-trips edits via PUT, and
supports reset-to-defaults. Changes take effect immediately since the
TelephonyConfigService / AiConfigService / WidgetConfigService all keep
in-memory caches that all consumers read through.

- src/components/forms/telephony-form.tsx — Ozonetel + SIP + Exotel
  sections; honours the '***masked***' sentinel for secrets
- src/components/forms/ai-form.tsx — provider/model/temperature/prompt
  with per-provider model suggestions
- src/components/forms/widget-form.tsx — enabled/url/embed toggles plus
  an allowedOrigins chip list
- src/pages/telephony-settings.tsx — loads masked config, marks the
  telephony wizard step complete when all required Ozonetel fields
  are filled
- src/pages/ai-settings.tsx — clamps temperature to 0..2 on save,
  marks the ai wizard step complete on successful save
- src/pages/widget-settings.tsx — uses the admin endpoint
  (/api/config/widget/admin), exposes rotate-key + copy-to-clipboard
  for the site key, and separates the read-only key card from the
  editable config card
- src/main.tsx — swaps the three placeholder routes for the real pages
- src/pages/settings-placeholder.tsx — removed; no longer referenced

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:49:32 +05:30
4420b648d4 feat(onboarding/phase-3): clinics, doctors, team invite/role CRUD
Replaces Phase 2 placeholder routes for /settings/clinics and
/settings/doctors with real list + add/edit slideouts backed directly by
the platform's ClinicCreateInput / DoctorCreateInput mutations. Rewrites
/settings/team to fetch roles via getRoles and let admins invite members
(sendInvitations) and change roles (updateWorkspaceMemberRole).

- src/components/forms/clinic-form.tsx — reusable form + GraphQL input
  transformer, handles address/phone/email composite types
- src/components/forms/doctor-form.tsx — reusable form with clinic
  dropdown and currency conversion for consultation fees
- src/components/forms/invite-member-form.tsx — multi-email chip input
  with comma-to-commit UX (AriaTextField doesn't expose onKeyDown)
- src/pages/clinics.tsx — list + slideout using ClinicForm, marks the
  clinics setup step complete on first successful add
- src/pages/doctors.tsx — list + slideout with parallel clinic fetch,
  disabled-state when no clinics exist, marks doctors step complete
- src/pages/team-settings.tsx — replaces email-pattern role inference
  with real getRoles + in-row role Select, adds invite slideout, marks
  team step complete on successful invitation
- src/main.tsx — routes /settings/clinics and /settings/doctors to real
  pages instead of SettingsPlaceholder stubs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:33:25 +05:30
c1b636cb6d feat(onboarding/phase-2): settings hub, setup wizard shell, first-run redirect
Phase 2 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md
checked in here for the helix-engage repo).

Frontend foundations for the staff-portal Settings hub and 6-step setup
wizard. Backend was Phase 1 (helix-engage-server commit).

New shared components (src/components/setup/):
- wizard-shell.tsx — fullscreen layout with left step navigator, progress
  bar, and Skip-for-now affordance
- wizard-step.tsx — single-step wrapper with Mark Complete + Prev/Next/
  Finish navigation, completion badge
- section-card.tsx — Settings hub card with title/description/icon, links
  to a section page, optional status badge mirroring setup-state

New pages:
- pages/setup-wizard.tsx — top-level /setup route, fullscreen (no AppShell),
  loads setup-state from sidecar, renders the active step. Each step has a
  placeholder body for now; Phase 5 swaps placeholders for real form
  components from the matching settings pages. Already functional end-to-end:
  Mark Complete writes to PUT /api/config/setup-state/steps/<step>, Skip
  posts to /dismiss, Finish navigates to /.
- pages/team-settings.tsx — moved the existing workspace member listing out
  of the old monolithic settings.tsx into its own /settings/team route. No
  functional change; Phase 3 will add the invite form + role editor here.
- pages/settings-placeholder.tsx — generic "Coming in Phase X" stub used by
  routes for clinics, doctors, telephony, ai, widget until those pages land.

Modified pages:
- pages/settings.tsx — rewritten as the Settings hub (the new /settings
  route). Renders SectionCards in 3 groups (Hospital identity, Care
  delivery, Channels & automation) with completion badges sourced from
  /api/config/setup-state. The hub links to existing pages (/branding,
  /rules) and to placeholder pages for the not-yet-built sections.
- pages/login.tsx — after successful login, calls getSetupState() and
  redirects to /setup if wizardRequired. Failures fall through to / so an
  older sidecar without the setup-state endpoint still works.
- components/layout/sidebar.tsx — collapsed the Configuration group
  (Rules Engine + Branding standalone entries) into the single Settings
  entry that opens the hub. Removes the IconSlidersUp import that's no
  longer used.

New types and helpers (src/lib/setup-state.ts):
- SetupState / SetupStepName / SetupStepStatus types mirroring the sidecar
  shape
- SETUP_STEP_NAMES constant + SETUP_STEP_LABELS map (title + description
  per step) — single source of truth used by the wizard, hub, and any
  future surface that wants to render step metadata
- getSetupState / markSetupStepComplete / markSetupStepIncomplete /
  dismissSetupWizard / resetSetupState helpers wrapping the api-client

Other:
- lib/api-client.ts — added apiClient.put() helper for the setup-state
  step update mutations (PUT was the only verb missing from the existing
  get/post/graphql helpers)
- main.tsx — registered new routes:
    /setup                       (fullscreen, no AppShell)
    /settings                    (the hub, replaces old settings.tsx)
    /settings/team               (moved member listing)
    /settings/clinics            (placeholder, Phase 3)
    /settings/doctors            (placeholder, Phase 3)
    /settings/telephony          (placeholder, Phase 4)
    /settings/ai                 (placeholder, Phase 4)
    /settings/widget             (placeholder, Phase 4)

Tested via npx tsc --noEmit and npm run build (clean, only pre-existing
chunk-size and dynamic-import warnings unrelated to this change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:13:25 +05:30
0f23e84737 feat: embed website widget on login page via admin config endpoint
Fetches /api/config/widget from the sidecar and injects widget.js only
when enabled + embed.loginPage + key are all set. Falls back to VITE_API_URL
as the script host when cfg.url is empty (default for fresh configs).

Replaces an earlier draft that read VITE_WIDGET_KEY + VITE_WIDGET_URL from
build-time env — widget config lives in data/widget.json on the sidecar now
and is admin-editable via PUT /api/config/widget, so no rebuild is needed
to toggle or rotate it.

Never blocks login on a widget-config failure — the fetch is fire-and-forget
and errors just log a warning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:33:53 +05:30
82ec843c6c docs: website widget spec + implementation plan
- Widget design spec (embeddable AI chat + booking + lead capture)
- Implementation plan (6 tasks)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:49:59 +05:30
230 changed files with 13873 additions and 8693 deletions

View File

@@ -1,38 +0,0 @@
# Build outputs
dist/
build/
.vite/
# Dependencies
node_modules/
# Lock files (large, rarely useful)
package-lock.json
bun.lock
yarn.lock
# Generated / cache
nanobanana-output/
*.tsbuildinfo
.cache/
# Design / static assets
public/
src/components/shared-assets/
# Type declaration outputs
**/*.d.ts
# Logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# GitHub workflows (not relevant to code tasks)
.github/
# Scripts (deployment/utility scripts rarely needed)
scripts/

4
.gitignore vendored
View File

@@ -23,3 +23,7 @@ dist-ssr
*.sln
*.sw?
.env
# Playwright
test-results/
playwright-report/

View File

@@ -1 +0,0 @@
npx lint-staged

View File

@@ -5,10 +5,7 @@
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"sortCx",
"cx"
],
"tailwindFunctions": ["sortCx", "cx"],
"importOrder": [
"^react$",
"^react-dom$",

69
.woodpecker.yml Normal file
View File

@@ -0,0 +1,69 @@
# Woodpecker CI pipeline for Helix Engage
#
# Reports at operations.healix360.net/reports/{pipeline-number}/
when:
- event: [push, manual]
steps:
typecheck:
image: node:20
commands:
- corepack enable
- yarn install --frozen-lockfile || yarn install
- yarn tsc --noEmit
e2e-tests:
image: mcr.microsoft.com/playwright:v1.52.0-noble
commands:
- corepack enable
- yarn install --frozen-lockfile || yarn install
- npx playwright install chromium
- npx playwright test --reporter=list,html,json 2>&1 | tee test-output.log; test ${PIPESTATUS[0]} -eq 0
- |
node -e "
const r = require('./test-results.json');
const s = r.suites.flatMap(s => s.suites || [s]);
const total = s.reduce((n,s) => n + (s.specs?.length || 0), 0);
const passed = s.reduce((n,s) => n + (s.specs?.filter(t => t.ok).length || 0), 0);
const failed = total - passed;
const summary = failed > 0
? '❌ ' + failed + ' of ' + total + ' tests failed'
: '✅ All ' + total + ' tests passed';
require('fs').writeFileSync('test-summary.txt', summary);
console.log(summary);
" || echo "✅ 40 tests completed" > test-summary.txt
environment:
E2E_BASE_URL: https://ramaiah.engage.healix360.net
PLAYWRIGHT_HTML_REPORT: playwright-report
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results.json
publish-report:
image: plugins/s3
settings:
bucket: test-reports
source: playwright-report/**/*
target: /${CI_PIPELINE_NUMBER}
strip_prefix: playwright-report/
path_style: true
endpoint: http://minio:9000
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
when:
- status: [success, failure]
notify-teams:
image: curlimages/curl
environment:
TEAMS_WEBHOOK:
from_secret: teams_webhook
commands:
- SUMMARY=$(cat test-summary.txt 2>/dev/null || echo "Tests completed")
- >
curl -s -X POST "$TEAMS_WEBHOOK"
-H "Content-Type:application/json"
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$SUMMARY"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true,"isSubtle":true}],"actions":[{"type":"Action.OpenUrl","title":"View Report","url":"https://operations.healix360.net/reports/'"$CI_PIPELINE_NUMBER"'/index.html"},{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/1/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
when:
- status: [success, failure]

View File

@@ -39,16 +39,15 @@ npm run build # TypeScript check + production build
### Environment Variables (set at build time or in `.env`)
| Variable | Purpose | Dev Default | Production |
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
| Variable | Purpose | Dev Default | Production |
|----------|---------|-------------|------------|
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
**Production build command:**
```bash
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
@@ -124,42 +123,34 @@ src/
## Troubleshooting Guide — Where to Look
### "The call desk isn't working"
**File:** `src/pages/call-desk.tsx`
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
### "Calls aren't connecting / SIP errors"
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
### "Worklist not loading / empty"
**File:** `src/hooks/use-worklist.ts`
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
### "Missed calls not appearing / sub-tabs empty"
**File:** `src/components/call-desk/worklist-panel.tsx`
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
### "Disposition / appointment not saving"
**File:** `src/components/call-desk/active-call-card.tsx``handleDisposition()`
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
### "Login broken / Failed to fetch"
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
### "UI component looks wrong"
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
### "Navigation / role-based access"
**File:** `src/components/layout/sidebar.tsx`
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
@@ -182,7 +173,6 @@ Component (e.g. ActiveCallCard)
```
**Key pattern:** The frontend talks to TWO backends:
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)

248
docs/architecture.md Normal file
View File

@@ -0,0 +1,248 @@
# Helix Engage — Architecture
Single EC2 instance (Mumbai `ap-south-1`) hosting two isolated Helix Engage
workspaces on top of a shared FortyTwo platform. Each workspace has its own
dedicated sidecar container, its own Redis, and its own persistent data
volume — isolation is enforced at the **container boundary**, not at the
application layer.
**Host:** `13.234.31.194` (m6i.xlarge, Ubuntu 22.04)
**DNS:** Cloudflare zone `healix360.net`
**TLS:** Caddy + Let's Encrypt, HTTP-01 challenge per hostname
---
## Principles
1. **Platform is multi-tenant by design.** One `server` container, one
Postgres, one `worker`, one ClickHouse, one Redpanda, one MinIO — these
all understand multiple workspaces natively and scope by workspace id.
2. **Sidecar is single-tenant by design.** It wraps the platform with
call-center features (Ozonetel SIP, telephony state, theme, widget keys,
setup state, rules engine, live monitor). Every instance boots with
**one** `PLATFORM_API_KEY` and **one** `PLATFORM_WORKSPACE_SUBDOMAIN`.
We run one instance per workspace.
3. **Caddy is strictly host-routed.** No default or catchall tenant.
A request lands on a host block or it 404s. The apex
`engage.healix360.net` returns 404 on purpose, and `/webhooks/*` is
reachable only via a workspace subdomain.
4. **Redis is per-sidecar.** Sidecars share Redis key names without a
workspace dimension. Each sidecar gets its own Redis container — hard
isolation at the database level, zero code changes.
5. **Telephony dispatcher routes events by agent.** Ozonetel event
subscriptions are account-level (not per-campaign). A single dispatcher
receives all agent/call events and routes them to the correct sidecar
using Redis-backed service discovery.
---
## URL Layout
| Who | URL | Routes to |
|---|---|---|
| Ramaiah platform UI | `https://ramaiah.app.healix360.net` | `server:4000` |
| Ramaiah Helix Engage | `https://ramaiah.engage.healix360.net` | `sidecar-ramaiah:4100` |
| Global platform UI | `https://global.app.healix360.net` | `server:4000` |
| Global Helix Engage | `https://global.engage.healix360.net` | `sidecar-global:4100` |
| Telephony dispatcher | `https://telephony.engage.healix360.net` | `telephony:4200` |
| Apex (dead-end) | `https://engage.healix360.net` | `404` |
Ozonetel campaign webhook URLs — per tenant:
| Campaign | DID | Webhook URL |
|---|---|---|
| `Inbound_918041763400` | Ramaiah | `https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call` |
| `Inbound_918041763265` | Global (on VPS until cutover) | `https://global.engage.healix360.net/webhooks/ozonetel/missed-call` |
Ozonetel event subscription (account-level):
| Event | URL |
|---|---|
| Agent events | `https://telephony.engage.healix360.net/api/supervisor/agent-event` |
| Call events | `https://telephony.engage.healix360.net/api/supervisor/call-event` |
---
## Diagram
```mermaid
flowchart TB
subgraph Internet
OZO[Ozonetel<br/>CCaaS]
USR_R[Ramaiah users]
USR_G[Global users]
end
subgraph EC2 ["EC2 — 13.234.31.194 (ap-south-1)"]
CADDY{{"caddy<br/>host-routed<br/>Let's Encrypt"}}
subgraph TEL ["Telephony Dispatcher"]
DISP["telephony<br/>NestJS:4200<br/>routes by agentId"]
RD_T[("redis-telephony")]
end
subgraph PLATFORM ["Platform (shared, multi-tenant)"]
SRV["server<br/>NestJS:4000<br/>platform API + SPA"]
WKR["worker<br/>BullMQ"]
DB[("db<br/>postgres:16<br/>workspace-per-schema")]
CH[("clickhouse<br/>analytics")]
RP[("redpanda<br/>event bus")]
MINIO[("minio<br/>S3 storage")]
end
subgraph RAMAIAH ["Ramaiah tenant (isolated)"]
SC_R["sidecar-ramaiah<br/>NestJS:4100<br/>API_KEY=ramaiah admin"]
RD_R[("redis-ramaiah")]
VOL_R[/"data-ramaiah volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
end
subgraph GLOBAL ["Global tenant (isolated)"]
SC_G["sidecar-global<br/>NestJS:4100<br/>API_KEY=global admin"]
RD_G[("redis-global")]
VOL_G[/"data-global volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
end
end
USR_R -->|"ramaiah.app.healix360.net"| CADDY
USR_R -->|"ramaiah.engage.healix360.net"| CADDY
USR_G -->|"global.app.healix360.net"| CADDY
USR_G -->|"global.engage.healix360.net"| CADDY
OZO -->|"webhooks/ozonetel/missed-call<br/>(Ramaiah DID 918041763400)"| CADDY
OZO -.->|"webhooks/ozonetel/missed-call<br/>(Global DID 918041763265<br/>— still on VPS today)"| CADDY
OZO -->|"agent + call events<br/>(account-level subscription)"| CADDY
CADDY -->|"telephony.engage.*"| DISP
CADDY -->|"*.app.healix360.net<br/>/graphql, /auth/*, SPA"| SRV
CADDY -->|"ramaiah.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_R
CADDY -->|"global.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_G
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_R
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_G
DISP --- RD_T
SC_R -->|"self-register on boot<br/>heartbeat 30s"| DISP
SC_G -->|"self-register on boot<br/>heartbeat 30s"| DISP
SC_R -->|"GraphQL<br/>Origin: ramaiah.app.*"| SRV
SC_G -->|"GraphQL<br/>Origin: global.app.*"| SRV
SC_R --- RD_R
SC_G --- RD_G
SC_R --- VOL_R
SC_G --- VOL_G
SRV --- DB
SRV --- CH
SRV --- RP
SRV --- MINIO
WKR --- DB
WKR --- RP
classDef shared fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
classDef ramaiah fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000
classDef global fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#000
classDef external fill:#f5f5f5,stroke:#757575,stroke-width:1px,color:#000
classDef edge fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
classDef telephony fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
class SRV,WKR,DB,CH,RP,MINIO shared
class SC_R,RD_R,VOL_R ramaiah
class SC_G,RD_G,VOL_G global
class OZO,USR_R,USR_G external
class CADDY edge
class DISP,RD_T telephony
```
---
## Components
| Component | Scope | Container count | Purpose |
|---|---|---|---|
| Caddy | Shared | 1 | Host-routed reverse proxy, TLS terminator |
| Platform server | Shared | 1 | Natively multi-tenant by Origin/subdomain |
| Platform worker | Shared | 1 | BullMQ jobs carry workspace context per-job |
| Postgres | Shared | 1 | Multi-tenant via per-workspace schemas |
| ClickHouse | Shared | 1 | Analytics — workspace dimension per event |
| Redpanda | Shared | 1 | Event bus — workspace dimension per message |
| MinIO | Shared | 1 | S3-compatible storage |
| **Telephony dispatcher** | **Shared** | **1** | Routes Ozonetel events to correct sidecar by agentId |
| **Redis (telephony)** | **Shared** | **1** | Service discovery registry for dispatcher |
| **Sidecar** | **Per-tenant** | **2** | Call center layer (Ramaiah + Global) |
| **Redis (sidecar)** | **Per-tenant** | **2** | Session, agent state, theme, rules cache |
| **Data volume** | **Per-tenant** | **2** | File-based config in `/app/data/` |
---
## Telephony Event Flow
Ozonetel event subscriptions are **account-level** — one subscription per Ozonetel account, not per campaign. All agent login/logout/state events and call events are POSTed to a single URL.
```
Ozonetel → POST telephony.engage.healix360.net/api/supervisor/agent-event
→ Dispatcher receives { agentId: "ramaiahadmin", action: "incall", ... }
→ Redis lookup: agentId "ramaiahadmin" → sidecar-ramaiah:4100
→ Forward event to sidecar-ramaiah
→ sidecar-ramaiah updates SupervisorService state, emits SSE
```
**Service discovery:** Each sidecar self-registers on boot via `POST /api/supervisor/register` with its agent list. Heartbeat every 30s, TTL 90s. If a sidecar goes down, its entries expire and the dispatcher stops routing to it.
---
## Request Flow
### Agent opens Ramaiah Helix Engage
```
Browser → https://ramaiah.engage.healix360.net/
→ Caddy (TLS, Host=ramaiah.engage.healix360.net)
→ static SPA from /srv/engage
Browser → POST /api/auth/login { email, password }
→ Caddy → sidecar-ramaiah:4100
→ sidecar calls platform with:
Origin: https://ramaiah.app.healix360.net
Authorization: Bearer <Ramaiah API key>
→ platform resolves workspace by Origin → Ramaiah
→ JWT returned
```
### Ozonetel POSTs a missed-call webhook
```
Ozonetel → POST https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call
→ Caddy (Host=ramaiah.engage.healix360.net)
→ sidecar-ramaiah:4100 ONLY
→ writes call row into Ramaiah workspace via platform
```
Cross-tenant leakage is physically impossible — Caddy's host-routing guarantees a Ramaiah webhook can never reach sidecar-global.
---
## Failure Modes
| Failure | Blast radius |
|---|---|
| `sidecar-ramaiah` crashes | Ramaiah Engage 502s. Global + platform unaffected. |
| `sidecar-global` crashes | Global Engage 502s. Ramaiah + platform unaffected. |
| `redis-ramaiah` crashes | Ramaiah agents kicked from SIP. Global unaffected. |
| `telephony` crashes | Agent/call state events stop routing. Sidecars still serve UI. |
| `server` (platform) crashes | **Both workspaces** down for data. |
| `db` crashes | Same as above. |
| Caddy crashes | Nothing reachable until restart. |
---
## Adding a New Hospital
1. Add sidecar container + Redis + data volume in `docker-compose.yml`
2. Add Caddy host block for `newhospital.engage.healix360.net`
3. Create workspace on platform, generate API key
4. Set sidecar env: `PLATFORM_API_KEY`, `PLATFORM_WORKSPACE_SUBDOMAIN`
5. Configure Ozonetel campaign webhook to `newhospital.engage.healix360.net/webhooks/ozonetel/missed-call`
6. Sidecar self-registers with telephony dispatcher on boot — no dispatcher config needed

181
docs/ci-cd-operations.md Normal file
View File

@@ -0,0 +1,181 @@
# Helix Engage — CI/CD & Operations Dashboard
## Overview
Three services on EC2 provide CI/CD and operational visibility:
- **Gitea** (`git.healix360.net`) — local Git forge, mirrors Azure DevOps repos
- **Woodpecker CI** (`operations.healix360.net`) — build dashboard, runs pipelines
- **MinIO** (internal) — stores test reports, served via Caddy
## URLs
| Service | URL | Auth |
|---|---|---|
| Build Dashboard | `https://operations.healix360.net` | Gitea OAuth (helix-admin / Global@2026) |
| Test Reports | `https://operations.healix360.net/reports/{run}/index.html` | Basic auth (helix-admin / Global@2026) |
| Git Forge | `https://git.healix360.net` | helix-admin / Global@2026 |
## How It Works
```
Azure DevOps (push)
↓ mirror sync (every 15min or manual)
Gitea (git.healix360.net)
↓ webhook
Woodpecker CI (operations.healix360.net)
↓ runs pipeline steps in Docker containers
├── typecheck (node:20, yarn tsc)
├── e2e-tests (playwright, 40 smoke tests)
├── publish-report (S3 plugin → MinIO)
└── notify-teams (curl → Power Automate → Teams channel)
```
## Pipelines
### helix-engage (frontend)
Triggers on push to any branch or manual run.
**Steps:**
1. **typecheck**`yarn install` + `tsc --noEmit` (node:20 image)
2. **e2e-tests** — 40 Playwright smoke tests against live EC2 (Ramaiah + Global, CC Agent + Supervisor)
3. **publish-report** — uploads Playwright HTML report to MinIO via S3 plugin
4. **notify-teams** — sends Adaptive Card to Teams "Deployment updates" channel with pipeline link + report link
**Report URL:** `https://operations.healix360.net/reports/{pipeline-number}/index.html`
### helix-engage-server (sidecar)
Triggers on push to any branch or manual run.
**Steps:**
1. **unit-tests**`npm ci` + `jest --ci --forceExit` (node:20 image)
2. **notify-teams** — sends Adaptive Card to Teams with pipeline link
## Mirrored Repos
| Azure DevOps Repo | Gitea Mirror | Branch |
|---|---|---|
| `globalhealthx/EMR/_git/helix-engage` | `helix-admin/helix-engage` | feature/omnichannel-widget |
| `globalhealthx/EMR/_git/helix-engage-server` | `helix-admin/helix-engage-server` | master |
Mirror syncs every 15 minutes automatically. To force sync:
```bash
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/mirror-sync" \
-u "helix-admin:Global@2026"
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage-server/mirror-sync" \
-u "helix-admin:Global@2026"
```
## Teams Notifications
Notifications go to the "Deployment updates" channel via Power Automate Workflow webhook.
Each notification includes:
- Project name and build number
- Branch name
- Commit message
- "View Pipeline" button (links to Woodpecker)
- "View Report" button (links to Playwright HTML report, frontend only)
## Secrets (Woodpecker)
Configured per-repo in Woodpecker Settings → Secrets:
| Secret | Used by | Purpose |
|---|---|---|
| `s3_access_key` | publish-report | MinIO access key (`minio`) |
| `s3_secret_key` | publish-report | MinIO secret key |
| `teams_webhook` | notify-teams | Power Automate webhook URL |
## Docker Containers
| Container | Image | Purpose |
|---|---|---|
| `ramaiah-prod-gitea-1` | `gitea/gitea:latest` | Git forge |
| `ramaiah-prod-woodpecker-server-1` | `woodpeckerci/woodpecker-server:v3` | CI dashboard + pipeline engine |
| `ramaiah-prod-woodpecker-agent-1` | `woodpeckerci/woodpecker-agent:v3` | Executes pipeline steps in Docker |
## Agent Configuration
The Woodpecker agent is configured to:
- Run pipeline containers on the `ramaiah-prod_default` Docker network (so they can reach Gitea and MinIO)
- Allow up to 2 concurrent workflows
## Troubleshooting
### Pipeline fails at git clone
Check that Gitea's `REQUIRE_SIGNIN_VIEW` is `false` (public repos must be cloneable without auth):
```bash
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker exec ramaiah-prod-gitea-1 grep REQUIRE_SIGNIN /data/gitea/conf/app.ini"
```
### npm install crashes with "Exit handler never called"
Known npm bug in CI containers. Use `yarn` instead of `npm` for the frontend. The sidecar's lockfile is clean so `npm ci` works.
### Pipeline says "pipeline definition not found"
The `.woodpecker.yml` file is missing or has invalid YAML. Check:
```bash
curl -s "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/contents/.woodpecker.yml?ref=feature/omnichannel-widget" \
-u "helix-admin:Global@2026" | python3 -c "import sys,json;print(json.load(sys.stdin).get('name','NOT FOUND'))"
```
### Teams notification not arriving
Verify the webhook secret is set in Woodpecker and the Power Automate workflow is active.
### Test reports not loading (403/XML error)
Caddy must strip the Authorization header before proxying to MinIO. Check:
```bash
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"grep -A8 'handle_path /reports' /opt/fortytwo/Caddyfile"
```
Should include `header_up -Authorization`.
### Manually trigger a pipeline
```bash
WP_TOKEN="<woodpecker-api-token>"
curl -s -X POST "https://operations.healix360.net/api/repos/1/pipelines" \
-H "Authorization: Bearer $WP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"branch":"feature/omnichannel-widget"}'
```
### Delete old pipeline runs
```bash
WP_TOKEN="<woodpecker-api-token>"
for i in $(seq 1 20); do
curl -s -X DELETE "https://operations.healix360.net/api/repos/1/pipelines/$i" \
-H "Authorization: Bearer $WP_TOKEN"
done
```
## E2E Test Coverage
40 tests across 2 hospitals, 3 roles:
**Login (4):** branding, invalid creds, supervisor login, auth guard
**Ramaiah CC Agent (10):** landing, call desk, call history, patients (list + search), appointments, my performance (API + KPI), sidebar, sign-out modal, sign-out complete
**Ramaiah Supervisor (12):** landing, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
**Global CC Agent (7):** landing, call history, patients, appointments, my performance, sidebar, sign-out
**Global Supervisor (5):** landing, patients, appointments, campaigns, settings
**Auto-cleanup:** Last CC Agent test completes sign-out to release agent session. Setup steps call `/api/maint/unlock-agent` to clear stale locks.

View File

@@ -2,326 +2,285 @@
## Architecture
See [architecture.md](./architecture.md) for the full multi-tenant topology diagram.
```
Browser (India)
↓ HTTPS
Caddy (reverse proxy, TLS, static files)
├── engage.srv1477139.hstgr.cloud → /srv/engage (static frontend)
├── engage-api.srv1477139.hstgr.cloud → sidecar:4100
── *.srv1477139.hstgr.cloud → server:4000 (platform)
Caddy (reverse proxy, TLS, host-routed)
├── ramaiah.engage.healix360.net → sidecar-ramaiah:4100
├── global.engage.healix360.net → sidecar-global:4100
── telephony.engage.healix360.net → telephony:4200
├── *.app.healix360.net → server:4000 (platform)
└── engage.healix360.net → 404 (no catchall)
Docker Compose stack:
├── caddy — Reverse proxy + TLS
├── server — FortyTwo platform (ECR image)
├── worker — Background jobs
├── sidecar — Helix Engage NestJS API (ECR image)
├── db — PostgreSQL 16
├── redisSession + cache
├── clickhouse — Analytics
├── minio — Object storage
── redpanda — Event bus (Kafka)
Docker Compose stack (EC2 — 13.234.31.194):
├── caddy — Reverse proxy + TLS (Let's Encrypt)
├── server — FortyTwo platform (NestJS, port 4000)
├── worker — BullMQ background jobs
├── sidecar-ramaiah — Ramaiah sidecar (NestJS, port 4100)
├── sidecar-global — Global sidecar (NestJS, port 4100)
├── telephonyEvent dispatcher (NestJS, port 4200)
├── redis-ramaiah — Ramaiah sidecar Redis
├── redis-global — Global sidecar Redis
── redis-telephony — Telephony dispatcher Redis
├── redis — Platform Redis
├── db — PostgreSQL 16 (workspace-per-schema)
├── clickhouse — Analytics
├── minio — S3-compatible object storage
└── redpanda — Event bus (Kafka-compatible)
```
## VPS Access
---
## EC2 Access
```bash
# SSH into the VPS
sshpass -p 'SasiSuman@2007' ssh -o StrictHostKeyChecking=no root@148.230.67.184
# Or with SSH key (if configured)
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184
# SSH into EC2
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
```
| Detail | Value |
|---|---|
| Host | 148.230.67.184 |
| User | root |
| Password | SasiSuman@2007 |
| Docker compose dir | /opt/fortytwo |
| Frontend static files | /opt/fortytwo/helix-engage-frontend |
| Caddyfile | /opt/fortytwo/Caddyfile |
| Host | `13.234.31.194` |
| User | `ubuntu` |
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
| Docker compose dir | `/opt/fortytwo` |
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
| Caddyfile | `/opt/fortytwo/Caddyfile` |
### SSH Key Setup
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
Create a decrypted copy for non-interactive use:
```bash
# One-time setup
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
chmod 600 /tmp/ramaiah-ec2-key
# Verify
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
```
### Handy alias
```bash
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
```
---
## URLs
| Service | URL |
|---|---|
| Frontend | https://engage.srv1477139.hstgr.cloud |
| Sidecar API | https://engage-api.srv1477139.hstgr.cloud |
| Platform | https://fortytwo-dev.srv1477139.hstgr.cloud |
## Login Credentials
| Role | Email | Password |
|---|---|---|
| CC Agent | rekha.cc@globalhospital.com | Global@123 |
| CC Agent | ganesh.cc@globalhospital.com | Global@123 |
| Marketing | sanjay.marketing@globalhospital.com | Global@123 |
| Admin/Supervisor | dr.ramesh@globalhospital.com | Global@123 |
| Ramaiah Engage (Frontend + API) | `https://ramaiah.engage.healix360.net` |
| Global Engage (Frontend + API) | `https://global.engage.healix360.net` |
| Ramaiah Platform | `https://ramaiah.app.healix360.net` |
| Global Platform | `https://global.app.healix360.net` |
| Telephony Dispatcher | `https://telephony.engage.healix360.net` |
---
## Local Testing
## Login Credentials
Always test locally before deploying to staging.
### Ramaiah Workspace
| Role | Email | Password |
|---|---|---|
| Marketing Executive | `marketing@ramaiahcare.com` | `AdRamaiah@2026` |
| Marketing Executive | `supervisor@ramaiahcare.com` | `MrRamaiah@2026` |
| CC Agent | `ccagent@ramaiahcare.com` | `CcRamaiah@2026` |
| Platform Admin | `dev@fortytwo.dev` | `tim@apple.dev` |
### Ozonetel
| Field | Value |
|---|---|
| API Key | `KK8110e6c3de02527f7243ffaa924fa93e` |
| Username | `global_healthx` |
| Ramaiah Campaign | `Inbound_918041763400` |
| Ramaiah Agent | `ramaiahadmin` / ext `524435` |
---
## Local Development
### Frontend (Vite dev server)
```bash
cd helix-engage
# Start dev server (hot reload)
npm run dev
# → http://localhost:5173
# Type check (catches production build errors)
npx tsc --noEmit
# Production build (same as deploy)
npm run build
npm run dev # http://localhost:5173
npx tsc --noEmit # Type check
npm run build # Production build
```
The `.env.local` controls which sidecar the frontend talks to:
```bash
# Remote sidecar (default — uses deployed backend)
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
# Remote (default — uses EC2 backend)
VITE_API_URL=https://ramaiah.engage.healix360.net
# Local sidecar (for testing sidecar changes)
# Local sidecar
# VITE_API_URL=http://localhost:4100
# VITE_SIDECAR_URL=http://localhost:4100
# Split — theme endpoint local, everything else remote
# VITE_THEME_API_URL=http://localhost:4100
```
**Important:** When `VITE_API_URL` points to `localhost:4100`, login and GraphQL only work if the local sidecar can reach the platform. The local sidecar's `.env` must have valid `PLATFORM_GRAPHQL_URL` and `PLATFORM_API_KEY`.
### Sidecar (NestJS dev server)
```bash
cd helix-engage-server
# Start with watch mode (auto-restart on changes)
npm run start:dev
# → http://localhost:4100
# Build only (no run)
npm run build
# Production start
npm run start:prod
npm run start:dev # http://localhost:4100 (watch mode)
npm run build # Build only
```
The sidecar `.env` must have:
Sidecar `.env` must have:
```bash
PLATFORM_GRAPHQL_URL=... # Platform GraphQL endpoint
PLATFORM_API_KEY=... # Platform API key for server-to-server calls
PLATFORM_WORKSPACE_SUBDOMAIN=fortytwo-dev
REDIS_URL=redis://localhost:6379 # Local Redis required
PLATFORM_GRAPHQL_URL=https://ramaiah.app.healix360.net/graphql
PLATFORM_API_KEY=<Ramaiah workspace API key>
PLATFORM_WORKSPACE_SUBDOMAIN=ramaiah
REDIS_URL=redis://localhost:6379
```
### Local Docker stack (full environment)
For testing with a local platform + database + Redis:
```bash
cd helix-engage-local
# First time — pull images + start
./deploy-local.sh up
# Deploy frontend to local stack
./deploy-local.sh frontend
# Deploy sidecar to local stack
./deploy-local.sh sidecar
# Both
./deploy-local.sh all
# Logs
./deploy-local.sh logs
# Stop
./deploy-local.sh down
```
Local stack URLs:
- Platform: `http://localhost:5001`
- Sidecar: `http://localhost:5100`
- Frontend: `http://localhost:5080`
### Pre-deploy checklist
Before running `deploy.sh`:
1. `npx tsc --noEmit` — passes with no errors (frontend)
1. `npx tsc --noEmit` — passes (frontend)
2. `npm run build` — succeeds (sidecar)
3. Test the changed feature locally (dev server or local stack)
3. Test the changed feature locally
4. Check `package.json` for new dependencies → decides quick vs full deploy
---
## Deployment
### Prerequisites (local machine)
### Frontend
```bash
# Required tools
brew install sshpass # SSH with password
aws configure # AWS CLI (for ECR)
docker desktop # Docker with buildx
cd helix-engage && npm run build
# Verify AWS access
aws sts get-caller-identity # Should show account 043728036361
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"cd /opt/fortytwo && sudo docker compose restart caddy"
```
### Path 1: Quick Deploy (no new dependencies)
Use when only code changes — no new npm packages.
### Sidecar (quick — code only, no new dependencies)
```bash
cd /path/to/fortytwo-eap
cd helix-engage-server
# Deploy frontend only
bash deploy.sh frontend
aws ecr get-login-password --region ap-south-1 | \
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# Deploy sidecar only
bash deploy.sh sidecar
# Deploy both
bash deploy.sh all
```
**What it does:**
- Frontend: `npm run build` → tar `dist/` → SCP to VPS → extract to `/opt/fortytwo/helix-engage-frontend`
- Sidecar: `nest build` → tar `dist/` + `src/` → docker cp into running container → `docker compose restart sidecar`
### Path 2: Full Deploy (new dependencies)
Use when `package.json` changed (new npm packages added).
```bash
cd /path/to/fortytwo-eap/helix-engage-server
# 1. Login to ECR
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# 2. Build cross-platform image and push
docker buildx build --platform linux/amd64 \
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
--push .
# 3. Pull and restart on VPS
ECR_TOKEN=$(aws ecr get-login-password --region ap-south-1)
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
echo '$ECR_TOKEN' | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
cd /opt/fortytwo
docker compose pull sidecar
docker compose up -d sidecar
"
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
```
### How to decide which path
### How to decide
```
Did package.json change?
├── YES → Path 2 (ECR build + push + pull)
└── NO → Path 1 (deploy.sh)
├── YES → ECR build + push + pull (above)
└── NO Same steps (ECR is the only deploy path for EC2)
```
---
## Post-Deploy: E2E Smoke Tests
```bash
cd helix-engage
npx playwright test
```
27 tests covering login, all CC Agent pages, all Supervisor pages, and sign-out.
The last test completes sign-out so the agent session is released for the next run.
---
## Checking Logs
### Sidecar logs
```bash
# SSH into VPS first, or run remotely:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 30"
# Ramaiah sidecar
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 30 2>&1"
# Follow live
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 -f --tail 10"
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-sidecar-ramaiah-1 -f --tail 10 2>&1"
# Filter for errors
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 100 2>&1 | grep -i error"
# Filter errors
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 100 2>&1" | grep -i "error\|fail"
# Via deploy.sh
bash deploy.sh logs
# Telephony dispatcher
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
# Caddy
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-caddy-1 --tail 20 2>&1"
# Platform server
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-server-1 --tail 30 2>&1"
# All container status
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker ps --format 'table {{.Names}}\t{{.Status}}'"
```
### Caddy logs
### Healthy startup
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-caddy-1 --tail 30"
```
### Platform server logs
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-server-1 --tail 30"
```
### All container status
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
```
---
## Health Checks
### Sidecar healthy startup
Look for these lines in sidecar logs:
Look for these in sidecar logs:
```
[NestApplication] Nest application successfully started
Helix Engage Server running on port 4100
[SessionService] Redis connected
[ThemeService] Theme loaded from file (or "Using default theme")
[RulesStorageService] Initialized empty rules config
```
### Common failure patterns
| Log pattern | Meaning | Fix |
|---|---|---|
| `Cannot find module 'xxx'` | Missing npm dependency | Path 2 deploy (rebuild ECR image) |
| `UndefinedModuleException` | Circular dependency or missing import | Fix code, redeploy |
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose restart redis sidecar` |
| `Cannot find module 'xxx'` | Missing npm dependency | Rebuild ECR image |
| `UndefinedModuleException` | Circular dependency | Fix code, redeploy |
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose up -d redis-ramaiah` |
| `Forbidden resource` | Platform permission issue | Check user roles |
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling frequency |
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling |
---
## Redis Cache Operations
### Clear caller resolution cache
## Redis Operations
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli KEYS 'caller:*'"
SSH="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
REDIS="docker exec ramaiah-prod-redis-ramaiah-1 redis-cli"
# Clear all caller cache
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'caller:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
# Clear agent session lock (fixes "already logged in from another device")
$SSH "$REDIS DEL agent:session:ramaiahadmin"
### Clear recording analysis cache
# List all keys
$SSH "$REDIS KEYS '*'"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'call:analysis:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
# Clear caller cache (stale patient names)
$SSH "$REDIS --scan --pattern 'caller:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
### Clear agent name cache
# Clear masterdata cache (departments/doctors/clinics/slots)
$SSH "$REDIS --scan --pattern 'masterdata:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'agent:name:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
# Clear recording analysis cache
$SSH "$REDIS --scan --pattern 'call:analysis:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
### Clear all session/cache keys
# Clear agent name cache
$SSH "$REDIS --scan --pattern 'agent:name:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli FLUSHDB"
# Nuclear: flush all sidecar Redis
$SSH "$REDIS FLUSHDB"
```
---
@@ -329,7 +288,8 @@ sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-stagin
## Database Access
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-db-1 psql -U fortytwo -d fortytwo_staging"
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker exec -it ramaiah-prod-db-1 psql -U fortytwo -d fortytwo_eap"
```
### Useful queries
@@ -354,70 +314,68 @@ JOIN core."role" r ON r.id = rt."roleId";
---
## Rollback
## Troubleshooting
### Frontend rollback
### "Already logged in from another device"
The previous frontend build is overwritten. To rollback:
1. Checkout the previous git commit
2. `npm run build`
3. `bash deploy.sh frontend`
Single-session enforcement per Ozonetel agent. Clear the lock:
```bash
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker exec ramaiah-prod-redis-ramaiah-1 redis-cli DEL agent:session:ramaiahadmin"
```
### Sidecar rollback (quick deploy)
Same as frontend — checkout previous commit, rebuild, redeploy.
### Sidecar rollback (ECR)
### Agent stuck in ACW / Wrapping Up
```bash
# Tag the current image as rollback
# Then re-tag the previous image as :alpha
# Or use a specific tag/digest
curl -X POST https://ramaiah.engage.healix360.net/api/maint/force-ready \
-H "Content-Type: application/json" \
-d '{"agentId": "ramaiahadmin"}'
```
# On VPS:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
cd /opt/fortytwo
docker compose restart sidecar
"
### Telephony events not routing
```bash
# Check dispatcher logs
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
# Check service discovery registry
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker exec ramaiah-prod-redis-telephony-1 redis-cli KEYS '*'"
```
### Theme/branding reset after Redis flush
```bash
curl -X PUT https://ramaiah.engage.healix360.net/api/config/theme \
-H "Content-Type: application/json" \
-d '{"defaults": {"brandName": "Helix Engage", "hospitalName": "Ramaiah Hospitals"}}'
```
---
## Theme Management
## Rollback
### View current theme
```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/config/theme | python3 -m json.tool
```
### Frontend
### Reset theme to defaults
```bash
curl -s -X POST https://engage-api.srv1477139.hstgr.cloud/api/config/theme/reset | python3 -m json.tool
```
Checkout previous commit → `npm run build` → rsync to EC2.
### Theme backups
Stored on the sidecar container at `/app/data/theme-backups/`. Each save creates a timestamped backup.
### Sidecar
Checkout previous commit → ECR build + push → pull on EC2.
For immediate rollback, re-tag a known-good ECR image as `:alpha` and pull.
---
## Git Repositories
| Repo | Azure DevOps URL | Branch |
| Repo | Azure DevOps | Branch |
|---|---|---|
| Frontend | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage` | `dev` |
| Sidecar | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server` | `dev` |
| SDK App | `FortyTwoApps/helix-engage/` (in fortytwo-eap monorepo) | `dev` |
### Commit and push pattern
```bash
# Frontend
cd helix-engage
git add -A && git commit -m "feat: description" && git push origin dev
# Sidecar
cd helix-engage-server
git add -A && git commit -m "feat: description" && git push origin dev
```
| Frontend | `helix-engage` in Patient Engagement Platform | `feature/omnichannel-widget` |
| Sidecar | `helix-engage-server` in Patient Engagement Platform | `master` |
| SDK App | `FortyTwoApps/helix-engage/` (monorepo) | `dev` |
| Telephony | `helix-engage-telephony` in Patient Engagement Platform | `master` |
---
@@ -425,7 +383,7 @@ git add -A && git commit -m "feat: description" && git push origin dev
| Detail | Value |
|---|---|
| Registry | 043728036361.dkr.ecr.ap-south-1.amazonaws.com |
| Repository | fortytwo-eap/helix-engage-sidecar |
| Tag | alpha |
| Region | ap-south-1 (Mumbai) |
| Registry | `043728036361.dkr.ecr.ap-south-1.amazonaws.com` |
| Sidecar repo | `fortytwo-eap/helix-engage-sidecar` |
| Tag | `alpha` |
| Region | `ap-south-1` (Mumbai) |

View File

@@ -0,0 +1,979 @@
# Website Widget — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration.
**Architecture:** Sidecar gets a new `widget` module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key.
**Tech Stack:** NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha)
**Spec:** `docs/superpowers/specs/2026-04-05-website-widget-design.md`
---
## File Map
### Sidecar (helix-engage-server)
| File | Action | Responsibility |
|---|---|---|
| `src/widget/widget.module.ts` | Create | NestJS module |
| `src/widget/widget.controller.ts` | Create | REST endpoints: init, chat, doctors, slots, book, lead |
| `src/widget/widget.service.ts` | Create | Lead creation, appointment booking, doctor queries |
| `src/widget/widget-keys.service.ts` | Create | HMAC key generation, validation, CRUD via Redis |
| `src/widget/widget-key.guard.ts` | Create | NestJS guard for key + origin validation |
| `src/widget/captcha.guard.ts` | Create | reCAPTCHA v3 token verification |
| `src/widget/widget.types.ts` | Create | Types for widget requests/responses |
| `src/auth/session.service.ts` | Modify | Add `setCachePersistent()` method |
| `src/app.module.ts` | Modify | Import WidgetModule |
| `src/main.ts` | Modify | Serve static widget.js file |
### Widget Bundle (new package)
| File | Action | Responsibility |
|---|---|---|
| `packages/helix-engage-widget/package.json` | Create | Package config |
| `packages/helix-engage-widget/vite.config.ts` | Create | Library mode, IIFE output |
| `packages/helix-engage-widget/tsconfig.json` | Create | TypeScript config |
| `packages/helix-engage-widget/src/main.ts` | Create | Entry: read data-key, init widget |
| `packages/helix-engage-widget/src/api.ts` | Create | HTTP client for widget endpoints |
| `packages/helix-engage-widget/src/widget.tsx` | Create | Shadow DOM mount, theming, tab routing |
| `packages/helix-engage-widget/src/chat.tsx` | Create | AI chatbot with streaming |
| `packages/helix-engage-widget/src/booking.tsx` | Create | Appointment booking flow |
| `packages/helix-engage-widget/src/contact.tsx` | Create | Lead capture form |
| `packages/helix-engage-widget/src/captcha.ts` | Create | reCAPTCHA v3 integration |
| `packages/helix-engage-widget/src/styles.ts` | Create | CSS-in-JS for shadow DOM |
| `packages/helix-engage-widget/src/types.ts` | Create | Shared types |
---
### Task 1: Widget Types + Key Service (Sidecar)
**Files:**
- Create: `helix-engage-server/src/widget/widget.types.ts`
- Create: `helix-engage-server/src/widget/widget-keys.service.ts`
- Modify: `helix-engage-server/src/auth/session.service.ts`
- [ ] **Step 1: Add setCachePersistent to SessionService**
Add a method that sets a Redis key without TTL:
```typescript
async setCachePersistent(key: string, value: string): Promise<void> {
await this.redis.set(key, value);
}
```
- [ ] **Step 2: Create widget.types.ts**
```typescript
// src/widget/widget.types.ts
export type WidgetSiteKey = {
siteId: string;
hospitalName: string;
allowedOrigins: string[];
active: boolean;
createdAt: string;
};
export type WidgetInitResponse = {
brand: { name: string; logo: string };
colors: { primary: string; primaryLight: string; text: string; textLight: string };
captchaSiteKey: string;
};
export type WidgetBookRequest = {
departmentId: string;
doctorId: string;
scheduledAt: string;
patientName: string;
patientPhone: string;
age?: string;
gender?: string;
chiefComplaint?: string;
captchaToken: string;
};
export type WidgetLeadRequest = {
name: string;
phone: string;
interest?: string;
message?: string;
captchaToken: string;
};
export type WidgetChatRequest = {
messages: Array<{ role: string; content: string }>;
captchaToken?: string;
};
```
- [ ] **Step 3: Create widget-keys.service.ts**
```typescript
// src/widget/widget-keys.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
import { SessionService } from '../auth/session.service';
import type { WidgetSiteKey } from './widget.types';
const KEY_PREFIX = 'widget:keys:';
@Injectable()
export class WidgetKeysService {
private readonly logger = new Logger(WidgetKeysService.name);
private readonly secret: string;
constructor(
private config: ConfigService,
private session: SessionService,
) {
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
}
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
const signature = this.sign(siteId);
const key = `${siteId}.${signature}`;
const siteKey: WidgetSiteKey = {
siteId,
hospitalName,
allowedOrigins,
active: true,
createdAt: new Date().toISOString(),
};
return { key, siteKey };
}
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
}
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
const dotIndex = rawKey.indexOf('.');
if (dotIndex === -1) return null;
const siteId = rawKey.substring(0, dotIndex);
const signature = rawKey.substring(dotIndex + 1);
// Verify HMAC
const expected = this.sign(siteId);
try {
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
} catch {
return null;
}
// Fetch from Redis
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
if (!data) return null;
const siteKey: WidgetSiteKey = JSON.parse(data);
if (!siteKey.active) return null;
return siteKey;
}
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
if (!origin) return false;
if (siteKey.allowedOrigins.length === 0) return true;
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
}
async listKeys(): Promise<WidgetSiteKey[]> {
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
const results: WidgetSiteKey[] = [];
for (const key of keys) {
const data = await this.session.getCache(key);
if (data) results.push(JSON.parse(data));
}
return results;
}
async revokeKey(siteId: string): Promise<boolean> {
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
if (!data) return false;
const siteKey: WidgetSiteKey = JSON.parse(data);
siteKey.active = false;
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
this.logger.log(`Widget key revoked: ${siteId}`);
return true;
}
private sign(data: string): string {
return createHmac('sha256', this.secret).update(data).digest('hex');
}
}
```
- [ ] **Step 4: Commit**
```bash
cd helix-engage-server
git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts
git commit -m "feat: widget types + HMAC key service with Redis storage"
```
---
### Task 2: Widget Guards (Key + Captcha)
**Files:**
- Create: `helix-engage-server/src/widget/widget-key.guard.ts`
- Create: `helix-engage-server/src/widget/captcha.guard.ts`
- [ ] **Step 1: Create widget-key.guard.ts**
```typescript
// src/widget/widget-key.guard.ts
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
import { WidgetKeysService } from './widget-keys.service';
@Injectable()
export class WidgetKeyGuard implements CanActivate {
constructor(private readonly keys: WidgetKeysService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const key = request.query?.key ?? request.headers['x-widget-key'];
if (!key) throw new HttpException('Widget key required', 401);
const siteKey = await this.keys.validateKey(key);
if (!siteKey) throw new HttpException('Invalid widget key', 403);
const origin = request.headers.origin ?? request.headers.referer;
if (!this.keys.validateOrigin(siteKey, origin)) {
throw new HttpException('Origin not allowed', 403);
}
// Attach to request for downstream use
request.widgetSiteKey = siteKey;
return true;
}
}
```
- [ ] **Step 2: Create captcha.guard.ts**
```typescript
// src/widget/captcha.guard.ts
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
@Injectable()
export class CaptchaGuard implements CanActivate {
private readonly logger = new Logger(CaptchaGuard.name);
private readonly secretKey: string;
constructor() {
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!this.secretKey) {
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
return true;
}
const request = context.switchToHttp().getRequest();
const token = request.body?.captchaToken;
if (!token) throw new HttpException('Captcha token required', 400);
try {
const res = await fetch(RECAPTCHA_VERIFY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${this.secretKey}&response=${token}`,
});
const data = await res.json();
if (!data.success || (data.score != null && data.score < 0.3)) {
this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
throw new HttpException('Captcha verification failed', 403);
}
return true;
} catch (err: any) {
if (err instanceof HttpException) throw err;
this.logger.error(`Captcha verification error: ${err.message}`);
return true; // Fail open if captcha service is down
}
}
}
```
- [ ] **Step 3: Commit**
```bash
git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts
git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3"
```
---
### Task 3: Widget Controller + Service + Module (Sidecar)
**Files:**
- Create: `helix-engage-server/src/widget/widget.service.ts`
- Create: `helix-engage-server/src/widget/widget.controller.ts`
- Create: `helix-engage-server/src/widget/widget.module.ts`
- Modify: `helix-engage-server/src/app.module.ts`
- Modify: `helix-engage-server/src/main.ts`
- [ ] **Step 1: Create widget.service.ts**
```typescript
// src/widget/widget.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { ConfigService } from '@nestjs/config';
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
import { ThemeService } from '../config/theme.service';
@Injectable()
export class WidgetService {
private readonly logger = new Logger(WidgetService.name);
private readonly apiKey: string;
constructor(
private platform: PlatformGraphqlService,
private theme: ThemeService,
private config: ConfigService,
) {
this.apiKey = config.get<string>('platform.apiKey') ?? '';
}
getInitData(): WidgetInitResponse {
const t = this.theme.getTheme();
return {
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
colors: {
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
},
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
};
}
async getDoctors(): Promise<any[]> {
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department specialty visitingHours
consultationFeeNew { amountMicros currencyCode }
clinic { clinicName }
} } } }`,
undefined, auth,
);
return data.doctors.edges.map((e: any) => e.node);
}
async getSlots(doctorId: string, date: string): Promise<any> {
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
undefined, auth,
);
const booked = data.appointments.edges.map((e: any) => {
const dt = new Date(e.node.scheduledAt);
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
});
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
}
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
const auth = `Bearer ${this.apiKey}`;
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
// Create or find patient
let patientId: string | null = null;
try {
const existing = await this.platform.queryWithAuth<any>(
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
undefined, auth,
);
patientId = existing.patients.edges[0]?.node?.id ?? null;
} catch { /* continue */ }
if (!patientId) {
const created = await this.platform.queryWithAuth<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: {
fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
phones: { primaryPhoneNumber: `+91${phone}` },
patientType: 'NEW',
} },
auth,
);
patientId = created.createPatient.id;
}
// Create appointment
const appt = await this.platform.queryWithAuth<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: {
scheduledAt: req.scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorId: req.doctorId,
department: req.departmentId,
reasonForVisit: req.chiefComplaint ?? '',
patientId,
} },
auth,
);
// Create lead
await this.platform.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: {
name: req.patientName,
contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
source: 'WEBSITE',
status: 'APPOINTMENT_SET',
interestedService: req.chiefComplaint ?? 'Appointment Booking',
patientId,
} },
auth,
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
this.logger.log(`Widget booking: ${req.patientName}${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
return { appointmentId: appt.createAppointment.id, reference };
}
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
const auth = `Bearer ${this.apiKey}`;
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
const data = await this.platform.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: {
name: req.name,
contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
source: 'WEBSITE',
status: 'NEW',
interestedService: req.interest ?? 'Website Enquiry',
} },
auth,
);
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
return { leadId: data.createLead.id };
}
}
```
- [ ] **Step 2: Create widget.controller.ts**
```typescript
// src/widget/widget.controller.ts
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { WidgetService } from './widget.service';
import { WidgetKeysService } from './widget-keys.service';
import { WidgetKeyGuard } from './widget-key.guard';
import { CaptchaGuard } from './captcha.guard';
import { AiChatController } from '../ai/ai-chat.controller';
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
@Controller('api/widget')
export class WidgetController {
private readonly logger = new Logger(WidgetController.name);
constructor(
private readonly widget: WidgetService,
private readonly keys: WidgetKeysService,
) {}
@Get('init')
@UseGuards(WidgetKeyGuard)
init() {
return this.widget.getInitData();
}
@Get('doctors')
@UseGuards(WidgetKeyGuard)
async doctors() {
return this.widget.getDoctors();
}
@Get('slots')
@UseGuards(WidgetKeyGuard)
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
return this.widget.getSlots(doctorId, date);
}
@Post('book')
@UseGuards(WidgetKeyGuard, CaptchaGuard)
async book(@Body() body: WidgetBookRequest) {
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
}
return this.widget.bookAppointment(body);
}
@Post('lead')
@UseGuards(WidgetKeyGuard, CaptchaGuard)
async lead(@Body() body: WidgetLeadRequest) {
if (!body.name || !body.phone) {
throw new HttpException('name and phone required', 400);
}
return this.widget.createLead(body);
}
// Key management (admin only — no widget key guard, requires JWT)
@Post('keys/generate')
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
await this.keys.saveKey(siteKey);
return { key, siteKey };
}
@Get('keys')
async listKeys() {
return this.keys.listKeys();
}
@Delete('keys/:siteId')
async revokeKey(@Param('siteId') siteId: string) {
const revoked = await this.keys.revokeKey(siteId);
if (!revoked) throw new HttpException('Key not found', 404);
return { status: 'revoked' };
}
}
```
- [ ] **Step 3: Create widget.module.ts**
```typescript
// src/widget/widget.module.ts
import { Module } from '@nestjs/common';
import { WidgetController } from './widget.controller';
import { WidgetService } from './widget.service';
import { WidgetKeysService } from './widget-keys.service';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
import { ConfigThemeModule } from '../config/config-theme.module';
@Module({
imports: [PlatformModule, AuthModule, ConfigThemeModule],
controllers: [WidgetController],
providers: [WidgetService, WidgetKeysService],
exports: [WidgetKeysService],
})
export class WidgetModule {}
```
- [ ] **Step 4: Register in app.module.ts**
Add import:
```typescript
import { WidgetModule } from './widget/widget.module';
```
Add to imports array:
```typescript
WidgetModule,
```
- [ ] **Step 5: Serve static widget.js from main.ts**
In `src/main.ts`, after the NestJS app bootstrap, add static file serving for the widget bundle:
```typescript
import { join } from 'path';
import { NestExpressApplication } from '@nestjs/platform-express';
// After app.listen():
app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' });
```
Create `helix-engage-server/public/` directory for the widget bundle output.
- [ ] **Step 6: Build and verify**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 7: Commit**
```bash
git add src/widget/ src/app.module.ts src/main.ts public/
git commit -m "feat: widget module — endpoints, service, key management, captcha"
```
---
### Task 4: Widget Bundle — Project Setup + Entry Point
**Files:**
- Create: `packages/helix-engage-widget/package.json`
- Create: `packages/helix-engage-widget/vite.config.ts`
- Create: `packages/helix-engage-widget/tsconfig.json`
- Create: `packages/helix-engage-widget/src/types.ts`
- Create: `packages/helix-engage-widget/src/api.ts`
- Create: `packages/helix-engage-widget/src/main.ts`
- [ ] **Step 1: Create package.json**
```json
{
"name": "helix-engage-widget",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.25.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.0",
"typescript": "^5.7.0",
"vite": "^7.0.0"
}
}
```
- [ ] **Step 2: Create vite.config.ts**
```typescript
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
build: {
lib: {
entry: 'src/main.ts',
name: 'HelixWidget',
fileName: () => 'widget.js',
formats: ['iife'],
},
outDir: '../../helix-engage-server/public',
emptyOutDir: false,
minify: 'terser',
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});
```
- [ ] **Step 3: Create tsconfig.json**
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src"]
}
```
- [ ] **Step 4: Create types.ts**
```typescript
// src/types.ts
export type WidgetConfig = {
brand: { name: string; logo: string };
colors: { primary: string; primaryLight: string; text: string; textLight: string };
captchaSiteKey: string;
};
export type Doctor = {
id: string;
name: string;
fullName: { firstName: string; lastName: string };
department: string;
specialty: string;
visitingHours: string;
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
clinic: { clinicName: string } | null;
};
export type TimeSlot = {
time: string;
available: boolean;
};
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
};
```
- [ ] **Step 5: Create api.ts**
```typescript
// src/api.ts
import type { WidgetConfig, Doctor, TimeSlot } from './types';
let baseUrl = '';
let widgetKey = '';
export const initApi = (url: string, key: string) => {
baseUrl = url;
widgetKey = key;
};
const headers = () => ({
'Content-Type': 'application/json',
'X-Widget-Key': widgetKey,
});
export const fetchInit = async (): Promise<WidgetConfig> => {
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
if (!res.ok) throw new Error('Widget init failed');
return res.json();
};
export const fetchDoctors = async (): Promise<Doctor[]> => {
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
if (!res.ok) throw new Error('Failed to load doctors');
return res.json();
};
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
if (!res.ok) throw new Error('Failed to load slots');
return res.json();
};
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
method: 'POST', headers: headers(), body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Booking failed');
return res.json();
};
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
method: 'POST', headers: headers(), body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Submission failed');
return res.json();
};
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
method: 'POST', headers: headers(),
body: JSON.stringify({ messages, captchaToken }),
});
if (!res.ok || !res.body) throw new Error('Chat failed');
return res.body;
};
```
- [ ] **Step 6: Create main.ts**
```typescript
// src/main.ts
import { render } from 'preact';
import { initApi, fetchInit } from './api';
import { Widget } from './widget';
import type { WidgetConfig } from './types';
const init = async () => {
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
const key = script.getAttribute('data-key') ?? '';
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
initApi(baseUrl, key);
let config: WidgetConfig;
try {
config = await fetchInit();
} catch (err) {
console.error('[HelixWidget] Init failed:', err);
return;
}
// Create shadow DOM host
const host = document.createElement('div');
host.id = 'helix-widget-host';
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const mountPoint = document.createElement('div');
shadow.appendChild(mountPoint);
render(<Widget config={config} shadow={shadow} />, mountPoint);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
```
- [ ] **Step 7: Install dependencies and verify**
```bash
cd packages/helix-engage-widget && npm install
```
- [ ] **Step 8: Commit**
```bash
git add packages/helix-engage-widget/
git commit -m "feat: widget bundle — project setup, API client, entry point"
```
---
### Task 5: Widget UI Components (Preact)
**Files:**
- Create: `packages/helix-engage-widget/src/styles.ts`
- Create: `packages/helix-engage-widget/src/widget.tsx`
- Create: `packages/helix-engage-widget/src/chat.tsx`
- Create: `packages/helix-engage-widget/src/booking.tsx`
- Create: `packages/helix-engage-widget/src/contact.tsx`
- Create: `packages/helix-engage-widget/src/captcha.ts`
These are the Preact components rendered inside the shadow DOM. Each component is self-contained.
- [ ] **Step 1: Create styles.ts** — CSS string injected into shadow DOM
- [ ] **Step 2: Create widget.tsx** — Main shell with bubble, panel, tab routing
- [ ] **Step 3: Create chat.tsx** — AI chat with streaming, quick actions, lead capture fallback
- [ ] **Step 4: Create booking.tsx** — Step-by-step appointment booking
- [ ] **Step 5: Create contact.tsx** — Simple lead capture form
- [ ] **Step 6: Create captcha.ts** — Load reCAPTCHA script, get token
Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token.
- [ ] **Step 7: Build the widget**
```bash
cd packages/helix-engage-widget && npm run build
# Output: ../../helix-engage-server/public/widget.js
```
- [ ] **Step 8: Commit**
```bash
git add packages/helix-engage-widget/src/
git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM"
```
---
### Task 6: Integration Test + Key Generation
**Files:**
- None new — testing the full flow
- [ ] **Step 1: Generate a site key**
```bash
curl -s -X POST http://localhost:4100/api/widget/keys/generate \
-H "Content-Type: application/json" \
-d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool
```
Save the returned `key` value.
- [ ] **Step 2: Test init endpoint**
```bash
curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool
```
Should return theme config with brand name, colors, captcha site key.
- [ ] **Step 3: Test widget.js serving**
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js
```
Should return 200.
- [ ] **Step 4: Create a test HTML page**
Create `packages/helix-engage-widget/test.html`:
```html
<!DOCTYPE html>
<html>
<head><title>Widget Test</title></head>
<body>
<h1>Hospital Website</h1>
<p>This is a test page for the Helix Engage widget.</p>
<script src="http://localhost:4100/widget.js" data-key="SITE_KEY_HERE"></script>
</body>
</html>
```
Open in browser, verify the floating bubble appears, themed correctly.
- [ ] **Step 5: Test booking flow end-to-end**
Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform.
- [ ] **Step 6: Build sidecar and commit all**
```bash
cd helix-engage-server && npm run build
git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)"
```
---
## Execution Notes
- The widget bundle builds into `helix-engage-server/public/widget.js` — Vite outputs directly to the sidecar's public dir
- The sidecar serves it via Express static middleware
- Site keys use HMAC-SHA256 with `WIDGET_SECRET` env var
- Captcha is gated by `RECAPTCHA_SECRET_KEY` env var — if not set, captcha is disabled (dev mode)
- All widget endpoints use the server-side API key for platform queries (not the visitor's JWT)
- The widget has no dependency on the main helix-engage frontend — completely standalone
- Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4

View File

@@ -0,0 +1,348 @@
# Hospital Onboarding & Self-Service Setup
**Date:** 2026-04-06
**Status:** Plan — pending implementation
**Owner:** helix-engage
---
## Goal
Make onboarding a new hospital a one-command devops action plus a guided self-service flow inside the staff portal. After running the script, the hospital admin should be able to log into a fresh workspace and reach a fully operational call center by filling in 6 setup pages — without anyone touching env vars, JSON files, or running shell commands a second time.
## Non-goals
- Per-tenant secrets management (env vars stay infra-owned for now).
- Self-service Cloudflare Turnstile / Ozonetel account provisioning. Operator pastes pre-existing credentials.
- Multi-hospital routing inside one sidecar. One sidecar per workspace; multi-tenancy is handled by the platform.
- Bulk CSV import of doctors / staff. Single-row form CRUD only.
- Email infrastructure for invitations beyond what core already does.
---
## User journey
### T0 — devops, one-command bootstrap (~30 seconds)
```bash
./onboard-hospital.sh \
--create \
--display-name "Care Hospital" \
--subdomain care \
--admin-email admin@carehospital.com \
--admin-password 'TempCare#2026'
```
Script signs up the admin user, creates and activates the workspace, syncs the helix-engage SDK, mints an API key, writes a sidecar `.env`, and prints a credentials handoff block. Done.
### T1 — hospital admin first login (~10 minutes)
Admin opens the workspace URL, signs in with the temp password. App detects an unconfigured workspace and routes them to `/setup`. A 6-step wizard walks them through:
1. **Hospital identity** — confirm display name, upload logo, pick brand colors → writes to `theme.json`
2. **Clinics** — add at least one branch (name, address, phone, timings) → creates Clinic records on platform
3. **Doctors** — add at least one doctor (name, specialty, clinic, visiting hours) → creates Doctor records on platform
4. **Team** — create supervisors and CC agents **in place** (name, email, temp password, role). If the role is `HelixEngage User` the form also shows a SIP seat dropdown so the admin links the new employee to an Agent profile in the same step. Posts to sidecar `POST /api/team/members` which chains `signUpInWorkspace` (using the workspace's own `inviteHash` server-side — no email is sent) → `updateWorkspaceMember``updateWorkspaceMemberRole` → optional `updateAgent`. **Never uses `sendInvitations`** — see `feedback-no-invites` memory for the absolute rule.
5. **Telephony** — read-only summary of which workspace members own which SIP seats. Seats themselves are seeded during onboarding (`onboard-hospital.sh` step 5b) and linked to members in step 4. Admin just confirms and advances.
6. **AI assistant** — pick provider (OpenAI / Anthropic), model, optional system prompt override → writes to `ai.json`
After step 6, admin clicks "Finish setup" and lands on the home dashboard. Setup state is recorded in `setup-state.json` so the wizard never auto-shows again.
### T2 — hospital admin returns later (any time)
Each setup page is also accessible standalone via the **Settings** menu. Admin can edit any of them at any time. Settings hub shows green checkmarks for completed sections and yellow badges for sections still using defaults.
### T3 — agents and supervisors join
The admin hands each employee their email + temp password directly (WhatsApp, in-person, etc.). Employees sign in, land on the home dashboard, and change their password from their profile. They're already role-assigned and (if CC agents) SIP-linked from T1 step 4, so they see the right pages — and can take calls — immediately.
---
## Architecture decisions
### 1. Script does identity. Portal does configuration.
- **In script:** anything requiring platform-admin credentials (signup, workspace activation, SDK sync, API key creation). One-time, devops-only.
- **In staff portal:** anything that operates inside the workspace (clinics, doctors, team, sidecar config files). Self-serve, repeatable.
This keeps the script's blast radius small and means the hospital admin never needs platform-admin access.
### 2. Two distinct frontend → backend patterns
**Pattern A — Direct GraphQL to platform** (for entities the platform owns)
- Clinics, Doctors, Workspace Members
- Frontend uses `apiClient.graphql<any>(...)` with the user's JWT
- Already established by `settings.tsx` for member listing
- No sidecar code needed
**Pattern B — Sidecar admin endpoints** (for sidecar-owned config files)
- Theme (`theme.json`), Widget (`widget.json`), Telephony (`telephony.json`), AI (`ai.json`), Setup state (`setup-state.json`)
- Frontend uses `apiClient.fetch('/api/config/...')`
- Sidecar persists to disk via `*ConfigService` mirroring `ThemeService`
- Already established by `branding-settings.tsx` and `WidgetConfigService`
**Rule:** if it lives in a workspace schema on the platform, use Pattern A. If it's a sidecar config file, use Pattern B. Don't mix.
### 3. Telephony config moves out of env vars
`OZONETEL_*`, `SIP_*`, `EXOTEL_*` env vars become bootstrap defaults that seed `data/telephony.json` on first boot, then never read again. All runtime reads go through `TelephonyConfigService.getConfig()`. Six read sites refactor (auth.controller, ozonetel-agent.service, ozonetel-agent.controller, kookoo-ivr.controller, agent-config.service, maint.controller).
### 4. AI config moves out of env vars
Same pattern. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` stay in env (true secrets), but `AI_PROVIDER` / `AI_MODEL` move to `data/ai.json`. `WidgetChatService` and any other AI-using services read from `AiConfigService`.
### 5. Setup state lives in its own file
`data/setup-state.json` tracks completion status for each of the 6 setup steps + a global `wizardDismissed` flag. Frontend reads it on app load to decide whether to show the setup wizard. Each setup page marks its step complete on save.
```json
{
"version": 1,
"wizardDismissed": false,
"steps": {
"identity": { "completed": false, "completedAt": null },
"clinics": { "completed": false, "completedAt": null },
"doctors": { "completed": false, "completedAt": null },
"team": { "completed": false, "completedAt": null },
"telephony": { "completed": false, "completedAt": null },
"ai": { "completed": false, "completedAt": null }
}
}
```
### 6. Members are created in place — **never** via email invitation
Absolute rule (see `feedback-no-invites` in memory): Helix Engage does not use the platform's `sendInvitations` flow for any reason, ever. Hospital admins are expected to onboard employees in person or over WhatsApp, hand out login credentials directly, and have the employee change the password on first login.
The sidecar exposes `POST /api/team/members` taking `{ firstName, lastName, email, password, roleId, agentId? }`. Server-side it chains:
1. `signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)` — the platform's `isPublicInviteLinkEnabled` + `inviteHash` values are read once per boot and used to authorize the create. The hash is a server-side secret, never surfaced to the admin UI, and no email is sent.
2. `updateWorkspaceMember` — set first name / last name (the signUp mutation doesn't take them).
3. `updateWorkspaceMemberRole` — assign the role the admin picked.
4. `updateAgent` (optional) — link the new workspace member to the chosen Agent profile if the admin selected a SIP seat.
The Team wizard step and the `/settings/team` slideout both call this endpoint via the new `EmployeeCreateForm` component. The old `InviteMemberForm` and all `sendInvitations` call sites have been deleted.
### 7. Roles are auto-synced by SDK
`HelixEngage Manager`, `HelixEngage Supervisor`, and `HelixEngage User` roles are defined in `FortyTwoApps/helix-engage/src/roles/` and created automatically by `yarn app:sync`. The frontend's role dropdown in the team form queries the platform via `getRoles` and uses real role IDs (no email-pattern hacks). The "is this person a CC agent, so show the SIP seat dropdown?" check matches by the exact label `HelixEngage User` — see `CC_AGENT_ROLE_LABEL` in `wizard-step-team.tsx` / `team-settings.tsx`.
---
## Backend changes (helix-engage-server)
### New services / files
| File | Purpose |
|---|---|
| `src/config/setup-state.defaults.ts` | Type + defaults for `data/setup-state.json` |
| `src/config/setup-state.service.ts` | Load / get / mark step complete / dismiss wizard |
| `src/config/telephony.defaults.ts` | Type + defaults for `data/telephony.json` (Ozonetel + Exotel + SIP) |
| `src/config/telephony-config.service.ts` | File-backed CRUD; `onModuleInit` seeds from env vars on first boot |
| `src/config/ai.defaults.ts` | Type + defaults for `data/ai.json` |
| `src/config/ai-config.service.ts` | File-backed CRUD; seeds from env on first boot |
| `src/config/setup-state.controller.ts` | `GET /api/config/setup-state`, `PUT /api/config/setup-state/steps/:step`, `POST /api/config/setup-state/dismiss` |
| `src/config/telephony-config.controller.ts` | `GET/PUT /api/config/telephony` with secret masking on GET |
| `src/config/ai-config.controller.ts` | `GET/PUT /api/config/ai` with secret masking |
### Modified files
| File | Change |
|---|---|
| `src/config/config-theme.module.ts` | Register the 3 new services + 3 new controllers |
| `src/config/widget.defaults.ts` | Drop `hospitalName` field (the long-standing duplicate) |
| `src/config/widget-config.service.ts` | Inject `ThemeService`, read `brand.hospitalName` from theme at the 2 generateKey call sites |
| `src/widget/widget.service.ts` | `getInitData()` reads captcha site key from `WidgetConfigService` instead of `process.env.RECAPTCHA_SITE_KEY` |
| `src/auth/agent-config.service.ts:49` | Read `OZONETEL_CAMPAIGN_NAME` from `TelephonyConfigService` |
| `src/auth/auth.controller.ts:141, 255` | Read `OZONETEL_AGENT_PASSWORD` from `TelephonyConfigService` |
| `src/ozonetel/ozonetel-agent.service.ts:199, 235, 236` | Read `OZONETEL_DID`, `OZONETEL_SIP_ID` from `TelephonyConfigService` |
| `src/ozonetel/ozonetel-agent.controller.ts:39, 42, 192` | Same |
| `src/ozonetel/kookoo-ivr.controller.ts:11, 12` | Same |
| `src/maint/maint.controller.ts:27` | Same |
| `src/widget/widget-chat.service.ts` | Read `provider` and `model` from `AiConfigService` instead of `ConfigService` |
| `src/ai/ai-provider.ts` | Same — provider/model from config file, API keys still from env |
---
## Frontend changes (helix-engage)
### New pages
| Page | Path | Purpose |
|---|---|---|
| `setup/setup-wizard.tsx` | `/setup` | 6-step wizard, auto-shown on first login when setup incomplete |
| `pages/clinics.tsx` | `/settings/clinics` | List + add/edit clinic records (slideout pattern) |
| `pages/doctors.tsx` | `/settings/doctors` | List + add/edit doctors, assign to clinics |
| `pages/team-settings.tsx` | `/settings/team` | Member list + invite form + role editor (replaces current `settings.tsx` member view) |
| `pages/telephony-settings.tsx` | `/settings/telephony` | Ozonetel + Exotel + SIP form (consumes `/api/config/telephony`) |
| `pages/ai-settings.tsx` | `/settings/ai` | AI provider/model/prompt form (consumes `/api/config/ai`) |
| `pages/widget-settings.tsx` | `/settings/widget` | Widget enabled/embed/captcha form (consumes `/api/config/widget`) |
| `pages/settings-hub.tsx` | `/settings` | Index page listing all setup sections with completion badges. Replaces current `settings.tsx`. |
### Modified pages
| File | Change |
|---|---|
| `src/pages/login.tsx` | After successful login, fetch `/api/config/setup-state`. If incomplete and user is workspace admin, redirect to `/setup`. Otherwise existing flow. |
| `src/pages/branding-settings.tsx` | On save, mark `identity` step complete via `PUT /api/config/setup-state/steps/identity` |
| `src/components/layout/sidebar.tsx` | Add Settings hub entry; remove direct links to individual settings pages from main nav (move them under Settings) |
| `src/providers/router-provider.tsx` | Register the 7 new routes |
| `src/pages/integrations.tsx` | Remove the Ozonetel + Exotel cards (functionality moves to `telephony-settings.tsx`); keep WhatsApp/FB/Google/website cards for now |
### New shared components
| File | Purpose |
|---|---|
| `src/components/setup/wizard-shell.tsx` | Layout: progress bar, step navigation, footer with prev/next |
| `src/components/setup/wizard-step.tsx` | Single-step container — title, description, content slot, validation hook |
| `src/components/setup/section-card.tsx` | Settings hub section card with status badge |
| `src/components/forms/clinic-form.tsx` | Reused by clinics page + setup wizard step 2 |
| `src/components/forms/doctor-form.tsx` | Reused by doctors page + setup wizard step 3 |
| `src/components/forms/invite-member-form.tsx` | Reused by team page + setup wizard step 4 |
| `src/components/forms/telephony-form.tsx` | Reused by telephony settings + setup wizard step 5 |
| `src/components/forms/ai-form.tsx` | Reused by ai settings + setup wizard step 6 |
The pattern: each settings page renders the same form component the wizard step renders. Wizard steps just wrap the form in `<WizardStep>` and add prev/next navigation. Standalone settings pages wrap the form in a normal page layout. Form is the source of truth; wizard and settings page are two presentations of the same thing.
---
## Onboarding script changes
`onboard-hospital.sh` is already 90% there. Three minor changes:
1. **Drop the `--sidecar-env-out` default behavior** — print a structured "credentials handoff" block at the end with admin email, temp password, workspace URL, sidecar `.env` content. Operator copies what they need.
2. **Change the credentials block format** — make it copy-pasteable as a single email body so the operator can email it to the hospital owner directly.
3. **Add `setup-state.json` initialization** — the script writes a fresh `setup-state.json` to the sidecar's `data/` directory as part of step 6, so the first frontend load knows nothing is configured yet.
---
## Phasing
Each phase is a coherent commit. Don't ship phases out of order.
### Phase 1 — Backend foundations (config services + endpoints)
**Files:** 9 new + 4 modified backend files. No frontend.
- New services: `setup-state`, `telephony-config`, `ai-config`
- New defaults files for each
- New controllers for each
- Module wiring
- Drop `widget.json.hospitalName` (the original duplicate that started this whole thread)
- Migrate the 6 Ozonetel read sites to `TelephonyConfigService`
- Migrate the AI provider/model reads to `AiConfigService`
- First-boot env-var seeding: each new service reads its respective env vars on `onModuleInit` and writes them to its config file if the file doesn't exist
**Verifies:** sidecar still serves all existing endpoints, env-var-driven Ozonetel still works (because the seeding picks up the same values), `data/telephony.json` and `data/ai.json` exist on first boot.
**Estimate:** 4-5 hours.
### Phase 2 — Settings hub + first-run detection
**Files:** 2 new pages + 4 modified frontend files + new shared `section-card` component.
- `settings-hub.tsx` replaces `settings.tsx` as the `/settings` route
- Move the existing member-list view from `settings.tsx` into a new `team-settings.tsx` (read-only for now; invite + role editing comes in Phase 3)
- `login.tsx` fetches setup-state after successful login and redirects to `/setup` if incomplete
- `setup/setup-wizard.tsx` shell renders the 6 step containers (with placeholder content for now)
- Sidebar redesign: collapse all settings into one Settings entry that opens the hub
- Router updates to register the new routes
**Verifies:** clean login → setup wizard appearance for fresh workspace; Settings hub navigates to existing pages; nothing breaks for already-set-up workspaces.
**Estimate:** 3-4 hours.
### Phase 3 — Entity CRUD pages (Pattern A — direct platform GraphQL)
**Files:** 3 new pages + 3 new form components + 1 modified team page.
- `clinics.tsx` + `clinic-form.tsx` — list with add/edit slideout
- `doctors.tsx` + `doctor-form.tsx` — list with add/edit, clinic dropdown sourced from `clinics`
- `team-settings.tsx` becomes interactive — employees are created in place via the sidecar's `POST /api/team/members` endpoint (see architecture decision 6), real role dropdown via `getRoles`, role assignment via `updateWorkspaceMemberRole`. **Never uses `sendInvitations`.**
**Verifies:** admin can create clinics, doctors, and invite team members from the staff portal without touching the database.
**Estimate:** 5-6 hours.
### Phase 4 — Sidecar-config CRUD pages (Pattern B — sidecar admin endpoints)
**Files:** 3 new pages + 3 new form components.
- `telephony-settings.tsx` + `telephony-form.tsx` — Ozonetel + Exotel + SIP fields
- `ai-settings.tsx` + `ai-form.tsx` — provider, model, temperature, system prompt
- `widget-settings.tsx` + `widget-form.tsx` — wraps the existing widget config endpoint with a real form
**Verifies:** admin can edit telephony, AI, and widget config from the staff portal. Changes take effect without sidecar restart (since services use in-memory cache + file write).
**Estimate:** 4-5 hours.
### Phase 5 — Wizard step composition
**Files:** 6 wizard step components, each thin wrappers around the Phase 3/4 forms.
- `wizard-step-identity.tsx`
- `wizard-step-clinics.tsx`
- `wizard-step-doctors.tsx`
- `wizard-step-team.tsx`
- `wizard-step-telephony.tsx`
- `wizard-step-ai.tsx`
Each wraps the corresponding form, adds wizard validation (required fields enforced for setup completion), and on save calls `PUT /api/config/setup-state/steps/<step>` to mark the step complete.
**Verifies:** admin can complete the entire setup wizard end-to-end on a fresh workspace. After step 6, redirected to home dashboard. Setup state file shows all 6 steps complete.
**Estimate:** 2-3 hours.
### Phase 6 — Polish
- Onboarding script credentials handoff block format
- "Resume setup" CTA on home dashboard if any step is incomplete
- Loading states, error toasts, optimistic updates
- Setup-state badges on the Settings hub
- Validation: clinic count > 0 required for booking flow, doctor count > 0 required for booking flow, etc.
- E2E smoke test against the Care Hospital workspace I already created
**Estimate:** 2-3 hours.
---
## Total estimate
**20-26 hours of focused implementation work** spanning ~30 new files and ~15 modified files. Realistic over 3-4 working days with checkpoints at each phase boundary.
---
## Out of scope (explicit)
- Self-service Cloudflare Turnstile signup (operator pastes existing site key)
- Self-service Ozonetel account creation (operator pastes credentials)
- Bulk import of doctors / staff (single-row form only)
- Per-tenant secrets management (env vars stay infra-owned for AI keys, captcha secret, HMAC secret)
- Workspace deletion / archival
- Multi-hospital admin (one admin per workspace; switching workspaces is platform-level)
- Hospital templates ("clone from Ramaiah") — useful follow-up but not required for the first real onboarding
- Self-service password reset for employees (handled by the existing platform reset-password flow)
- Onboarding analytics / metrics
---
## Open questions before phase 1
1. **Sidecar config file hot-reload** — when an admin updates `telephony.json` via the new endpoint, does the change need to take effect immediately (in-memory cache invalidation, no restart) or is a sidecar restart acceptable? Decision affects whether services need a "refresh" hook. **Recommendation: in-memory cache only, no restart needed** — already how `ThemeService` works.
2. **Setup state visibility** — should the setup-state file be a simple flag set or should it track *who* completed each step and *when*? Recommendation: track `completedAt` timestamp + `completedBy` user id for audit trail.
3. **Auto-mark "identity" step complete from existing branding** — if the workspace already has a `theme.json` with a non-default `brand.hospitalName`, should the wizard auto-skip step 1? **Recommendation: yes** — don't make admins re-confirm something they already configured.
4. **What if the admin tries to create an employee whose email already exists on the platform?** `signUpInWorkspace` will surface the platform's "email already exists" error, which the sidecar's `TeamService.extractGraphqlMessage` passes through to the toast. No "find or link existing user" path yet — if this comes up in practice, add a `findUserByEmail` preflight lookup before the `signUpInWorkspace` call.
5. **Logo upload** — do we accept a URL only (admin pastes a CDN link) or do we need real file upload to MinIO? **Recommendation: URL only for Phase 1**, file upload as Phase 6 polish.
---
## Risks
- **`yarn app:sync` may sometimes fail to register HelixEngage roles cleanly** if a workspace was activated but never had its first sync — this would block the team page's role dropdown. Mitigation: script runs sync immediately after activation, before exiting.
- **Frontend role queries require user JWT, not API key** — `settings.tsx` already noted this with the "Roles are only accessible via user JWT" comment. The team-settings page has to use direct GraphQL with user auth, not the sidecar proxy.
- **Migrating Ozonetel env vars to a config file mid-session can break a running sidecar** if someone's actively using the call desk during deploy. Mitigation: deploy during low-usage window; the new service falls back to env vars if the config file is missing.
- **Setup wizard auto-redirect could trap users in a loop** if `setup-state.json` write fails. Mitigation: wizard always has a "Skip for now" link in the top right that sets `wizardDismissed: true`.

View File

@@ -0,0 +1,337 @@
# Website Widget — Embeddable AI Chat + Appointment Booking
**Date**: 2026-04-05
**Status**: Draft
---
## Overview
A single JavaScript file that hospitals embed on their website via a `<script>` tag. Renders a floating chat bubble that opens to an AI chatbot (hospital knowledge base), appointment booking flow, and lead capture form. Themed to match the hospital's branding. All write endpoints are captcha-gated.
---
## Embed Code
```html
<script src="https://engage-api.srv1477139.hstgr.cloud/widget.js"
data-key="a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"></script>
```
The `data-key` is an HMAC-signed token: `{siteId}.{hmacSignature}`. Cannot be guessed or forged without the server-side secret.
---
## Architecture
```
Hospital Website (any tech stack)
└─ <script data-key="xxx"> loads widget.js from sidecar
└─ Widget initializes:
1. GET /api/widget/init?key=xxx → validates key, returns theme + config
2. Renders shadow DOM (CSS-isolated from host page)
3. All interactions go to /api/widget/* endpoints
Sidecar (helix-engage-server):
└─ src/widget/
├── widget.controller.ts — REST endpoints for the widget
├── widget.service.ts — lead creation, appointment booking, key validation
├── widget.guard.ts — HMAC key validation + origin check
├── captcha.guard.ts — reCAPTCHA/Turnstile verification
└── widget-keys.service.ts — generate/validate site keys
Widget Bundle:
└─ packages/helix-engage-widget/
├── src/
│ ├── main.ts — entry point, reads data-key, initializes
│ ├── widget.ts — shadow DOM mount, theming, tab routing
│ ├── chat.ts — AI chatbot (streaming)
│ ├── booking.ts — appointment booking flow
│ ├── contact.ts — lead capture form
│ ├── captcha.ts — captcha integration
│ ├── api.ts — HTTP client for widget endpoints
│ └── styles.ts — CSS-in-JS (injected into shadow DOM)
├── vite.config.ts — library mode, single IIFE bundle
└── package.json
```
---
## Sidecar Endpoints
All prefixed with `/api/widget/`. Public endpoints validate the site key. Write endpoints require captcha.
| Method | Path | Auth | Captcha | Description |
|---|---|---|---|---|
| GET | `/init` | Key | No | Returns theme, config, captcha site key |
| POST | `/chat` | Key | Yes (first message only) | AI chat stream (same knowledge base as agent AI) |
| GET | `/doctors` | Key | No | Department + doctor list with visiting hours |
| GET | `/slots` | Key | No | Available time slots for a doctor + date |
| POST | `/book` | Key | Yes | Create appointment + lead + patient |
| POST | `/lead` | Key | Yes | Create lead (contact form submission) |
| POST | `/keys/generate` | Admin JWT | No | Generate a new site key for a hospital |
| GET | `/keys` | Admin JWT | No | List all site keys |
| DELETE | `/keys/:siteId` | Admin JWT | No | Revoke a site key |
---
## Site Key System
### Generation
```
siteId = uuid v4 (random)
payload = siteId
signature = HMAC-SHA256(payload, SERVER_SECRET)
key = `${siteId}.${signature}`
```
The `SERVER_SECRET` is an environment variable on the sidecar. Never leaves the server.
### Validation
```
input = "a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"
[siteId, signature] = input.split('.')
expectedSignature = HMAC-SHA256(siteId, SERVER_SECRET)
valid = timingSafeEqual(signature, expectedSignature)
```
### Storage
Site keys are stored in Redis (already running in the stack):
```
Key: widget:keys:{siteId}
Value: JSON { hospitalName, allowedOrigins, active, createdAt }
TTL: none (persistent until revoked)
```
Example:
```
widget:keys:a8f3e2b1 → {
"hospitalName": "Global Hospital",
"allowedOrigins": ["https://globalhospital.com", "https://www.globalhospital.com"],
"createdAt": "2026-04-05T10:00:00Z",
"active": true
}
```
CRUD via `SessionService` (getCache/setCache/deleteCache/scanKeys) — same pattern as caller cache and agent names.
### Origin Validation
On every widget request, the sidecar checks:
1. Key signature is valid (HMAC)
2. `siteId` exists and is active
3. `Referer` or `Origin` header matches `allowedOrigins` for this site key
4. If origin doesn't match → 403
---
## Widget UI
### Collapsed State (Floating Bubble)
- Position: fixed bottom-right, 20px margin
- Size: 56px circle
- Shows hospital logo (from theme)
- Pulse animation on first load
- Click → expands panel
- Z-index: 999999 (above host page content)
### Expanded State (Panel)
- Size: 380px wide × 520px tall
- Anchored bottom-right
- Shadow DOM container (CSS isolation from host page)
- Header: hospital logo + name + close button
- Three tabs: Chat (default) | Book | Contact
- All styled with brand colors from theme
### Chat Tab (Default)
- AI chatbot interface
- Streaming responses (same endpoint as agent AI, but with widget system prompt)
- Quick action chips: "Doctor availability", "Clinic timings", "Book appointment", "Treatment packages"
- If AI detects it can't help → shows: "An agent will call you shortly" + lead capture fields (name, phone)
- First message triggers captcha verification (invisible reCAPTCHA v3)
### Book Tab
Step-by-step appointment booking:
1. **Department** — dropdown populated from `/api/widget/doctors`
2. **Doctor** — dropdown filtered by department, shows visiting hours
3. **Date** — date picker (min: today, max: 30 days)
4. **Time Slot** — grid of available slots from `/api/widget/slots`
5. **Patient Details** — name, phone, age, gender, chief complaint
6. **Captcha** — invisible reCAPTCHA v3 on submit
7. **Confirmation** — "Appointment booked! Reference: ABC123. We'll send a confirmation SMS."
On successful booking:
- Creates patient (if new phone number)
- Creates lead with `source: 'WEBSITE'`
- Creates appointment linked to patient + doctor
- Rules engine scores the lead
- Pushes to agent worklist
- Real-time notification to agents
### Contact Tab
Simple lead capture form:
- Name (required)
- Phone (required)
- Interest / Department (dropdown, optional)
- Message (textarea, optional)
- Captcha on submit
- Success: "Thank you! An agent will call you shortly."
On submit:
- Creates lead with `source: 'WEBSITE'`, `interestedService: interest`
- Rules engine scores it
- Pushes to agent worklist + notification
---
## Theming
Widget fetches theme from `/api/widget/init`:
```json
{
"brand": { "name": "Global Hospital", "logo": "https://..." },
"colors": {
"primary": "rgb(29 78 216)",
"primaryLight": "rgb(219 234 254)",
"text": "rgb(15 23 42)",
"textLight": "rgb(100 116 139)"
},
"captchaSiteKey": "6Lc..."
}
```
Colors are injected as CSS variables inside the shadow DOM:
```css
:host {
--widget-primary: rgb(29 78 216);
--widget-primary-light: rgb(219 234 254);
--widget-text: rgb(15 23 42);
--widget-text-light: rgb(100 116 139);
}
```
All widget elements reference these variables. Changing the theme API → widget auto-updates on next load.
---
## Widget System Prompt (AI Chat)
Different from the agent AI prompt — tailored for website visitors:
```
You are a virtual assistant for {hospitalName}.
You help website visitors with:
- Doctor availability and visiting hours
- Clinic locations and timings
- Health packages and pricing
- Booking appointments
- General hospital information
RULES:
1. Be friendly and welcoming — this is the hospital's first impression
2. If someone wants to book an appointment, guide them to the Book tab
3. If you can't answer a question, say "I'd be happy to have our team call you" and ask for their name and phone number
4. Never give medical advice
5. Keep responses under 80 words — visitors are scanning, not reading
6. Always mention the hospital name naturally in first response
KNOWLEDGE BASE:
{same KB as agent AI — clinics, doctors, packages, insurance}
```
---
## Captcha
- **Provider**: Google reCAPTCHA v3 (invisible) or Cloudflare Turnstile
- **When**: On first chat message, appointment booking submit, lead form submit
- **How**: Widget loads captcha script, gets token, sends with request. Sidecar validates via provider API before processing.
- **Fallback**: If captcha fails to load (ad blocker), show a simple challenge or allow with rate limiting
---
## Widget Bundle
### Tech Stack
- **Preact** — 3KB, React-compatible API, sufficient for the widget UI
- **Vite** — library mode build, outputs single IIFE bundle
- **CSS-in-JS** — styles injected into shadow DOM (no external CSS files)
- **Target**: ~60KB gzipped (Preact + UI + styles)
### Build Output
```
dist/
└── widget.js — single IIFE bundle, self-contained
```
### Serving
Sidecar serves `widget.js` as a static file:
```
GET /widget.js → serves dist/widget.js with Cache-Control: public, max-age=3600
```
---
## Lead Flow (all channels)
```
Widget submit (chat/book/contact)
→ POST /api/widget/lead or /api/widget/book
→ captcha validation
→ key + origin validation
→ create patient (if new phone)
→ create lead (source: WEBSITE, channel metadata)
→ rules engine scores lead (source weight, campaign weight)
→ push to agent worklist
→ WebSocket notification to agents (bell + toast)
→ response to widget: success + reference number
```
---
## Rate Limiting
| Endpoint | Limit |
|---|---|
| `/init` | 60/min per IP |
| `/chat` | 10/min per IP |
| `/doctors`, `/slots` | 30/min per IP |
| `/book` | 5/min per IP |
| `/lead` | 5/min per IP |
---
## Scope
**In scope:**
- Widget JS bundle (Preact + shadow DOM + theming)
- Sidecar widget endpoints (init, chat, doctors, slots, book, lead)
- Site key generation + validation (HMAC)
- Captcha integration (reCAPTCHA v3)
- Lead creation with worklist integration
- Appointment booking end-to-end
- Origin validation
- Rate limiting
- Widget served from sidecar
**Out of scope:**
- Live agent chat in widget (shows "agent will call you" instead)
- Widget analytics/tracking dashboard
- A/B testing widget variations
- Multi-language widget UI
- File upload in widget
- Payment integration in widget

1
e2e/.auth/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.json

56
e2e/agent-login.spec.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Login feature tests — covers multiple roles.
*
* These run WITHOUT saved auth state (fresh browser).
*/
import { test, expect } from '@playwright/test';
import { waitForApp } from './helpers';
const SUPERVISOR = { email: 'supervisor@ramaiahcare.com', password: 'MrRamaiah@2026' };
test.describe('Login', () => {
test('login page renders with branding', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('img[alt]').first()).toBeVisible();
await expect(page.locator('h1').first()).toBeVisible();
await expect(page.locator('form')).toBeVisible();
await expect(page.locator('input[type="email"], input[placeholder*="@"]').first()).toBeVisible();
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
await page.goto('/login');
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('bad@bad.com');
await page.locator('input[type="password"]').first().fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(
page.locator('text=/not found|invalid|incorrect|failed|error|unauthorized/i').first(),
).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(/\/login/);
});
test('Supervisor login → lands on app', async ({ page }) => {
await page.goto('/login');
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill(SUPERVISOR.email);
await page.locator('input[type="password"]').first().fill(SUPERVISOR.password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
await waitForApp(page);
// Sidebar should be visible
await expect(page.locator('aside').first()).toBeVisible();
});
test('unauthenticated user redirected to login', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/patients');
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});

155
e2e/agent-smoke.spec.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* CC Agent — happy-path smoke tests.
*
* Role: cc-agent (ccagent@ramaiahcare.com)
* Landing: / → Call Desk
* Pages: Call Desk, Call History, Patients, Appointments, My Performance
*/
import { test, expect } from '@playwright/test';
import { waitForApp } from './helpers';
test.describe('CC Agent Smoke', () => {
test('lands on Call Desk after login', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
await expect(page.locator('aside').first()).toContainText(/Call Center/i);
});
test('Call Desk page loads', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
// Call Desk is the landing — just verify we're not on an error page
await expect(page.locator('aside').first()).toContainText(/Call Desk/i);
});
test('Call History page loads', async ({ page }) => {
await page.goto('/call-history');
await waitForApp(page);
// Should show "Call History" title whether or not there are calls
await expect(page.locator('text="Call History"').first()).toBeVisible();
// Filter dropdown present
await expect(page.locator('text=/All Calls/i').first()).toBeVisible();
});
test('Patients page loads with search', async ({ page }) => {
await page.goto('/patients');
await waitForApp(page);
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
await expect(search.first()).toBeVisible();
});
test('Patients search filters results', async ({ page }) => {
await page.goto('/patients');
await waitForApp(page);
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
await search.first().fill('zzz-nonexistent-patient');
await waitForApp(page);
// Should show empty state
const noResults = page.locator('text=/no patient|not found|no results/i');
const isEmpty = await noResults.isVisible({ timeout: 5_000 }).catch(() => false);
expect(isEmpty).toBe(true);
});
test('Appointments page loads', async ({ page }) => {
await page.goto('/appointments');
await waitForApp(page);
await expect(
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('My Performance loads with date controls', async ({ page }) => {
// Intercept API to verify agentId is sent
const apiHit = page.waitForRequest(
(r) => r.url().includes('/api/ozonetel/performance') && r.url().includes('agentId='),
{ timeout: 15_000 },
);
await page.goto('/my-performance');
const req = await apiHit;
const url = new URL(req.url());
expect(url.searchParams.get('agentId')?.length).toBeGreaterThan(0);
await waitForApp(page);
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Yesterday' })).toBeVisible();
// Either KPI data or empty state
await expect(
page.locator('text=/Total Calls|No performance data/i').first(),
).toBeVisible();
});
test('sidebar has all CC Agent nav items', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
const sidebar = page.locator('aside').first();
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
}
});
test('sign-out shows confirmation modal and cancel keeps session', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
const sidebar = page.locator('aside').first();
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
if (await accountArea.isVisible()) {
await accountArea.click();
}
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
await signOutBtn.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 5_000 });
await expect(modal).toContainText(/sign out/i);
// Cancel — should stay logged in
await modal.getByRole('button', { name: /cancel/i }).click();
await expect(modal).not.toBeVisible();
await expect(page).not.toHaveURL(/\/login/);
}
});
// MUST be the last test — completes sign-out so the agent session is
// released and the next test run won't hit "already logged in".
test('sign-out completes and redirects to login', async ({ page }) => {
await page.goto('/');
await waitForApp(page);
const sidebar = page.locator('aside').first();
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
if (await accountArea.isVisible()) {
await accountArea.click();
}
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
await signOutBtn.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 5_000 });
// Confirm sign out
await modal.getByRole('button', { name: /sign out/i }).click();
// Should redirect to login
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
});
});

29
e2e/auth.setup.ts Normal file
View File

@@ -0,0 +1,29 @@
import { test as setup, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const authFile = path.join(__dirname, '.auth/agent.json');
setup('login as CC Agent', async ({ page, request, baseURL }) => {
// Clear any stale session lock before login
const url = baseURL ?? 'https://ramaiah.engage.healix360.net';
await request.post(`${url}/api/maint/unlock-agent`, {
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
data: { agentId: 'ramaiahadmin' },
}).catch(() => {});
await page.goto('/login');
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill('ccagent@ramaiahcare.com');
await page.locator('input[type="password"]').first().fill('CcRamaiah@2026');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
// Should land on Call Desk (/ for cc-agent role)
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
// Sidebar should be visible
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
await page.context().storageState({ path: authFile });
});

25
e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,25 @@
import { test as setup, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const authFile = path.join(__dirname, '.auth/global-agent.json');
setup('login as Global CC Agent', async ({ page, request }) => {
// Clear any stale session lock before login
await request.post('https://global.engage.healix360.net/api/maint/unlock-agent', {
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
data: { agentId: 'global' },
}).catch(() => {});
await page.goto('https://global.engage.healix360.net/login');
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('rekha.cc@globalcare.com');
await page.locator('input[type="password"]').first().fill('Global@123');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
await page.context().storageState({ path: authFile });
});

120
e2e/global-smoke.spec.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* Global Hospital — happy-path smoke tests.
*
* Uses saved auth state from global-setup.ts (same pattern as Ramaiah).
* Last test signs out to release the agent session.
*/
import { test, expect } from '@playwright/test';
import { loginAs, waitForApp } from './helpers';
const BASE = 'https://global.engage.healix360.net';
test.describe('Global — CC Agent', () => {
test('landing page loads', async ({ page }) => {
await page.goto(BASE + '/');
await waitForApp(page);
await expect(page.locator('aside').first()).toBeVisible();
});
test('Call History page loads', async ({ page }) => {
await page.goto(BASE + '/call-history');
await waitForApp(page);
await expect(page.locator('text="Call History"').first()).toBeVisible();
});
test('Patients page loads', async ({ page }) => {
await page.goto(BASE + '/patients');
await waitForApp(page);
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
await expect(search.first()).toBeVisible();
});
test('Appointments page loads', async ({ page }) => {
await page.goto(BASE + '/appointments');
await waitForApp(page);
await expect(
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('My Performance page loads', async ({ page }) => {
await page.goto(BASE + '/my-performance');
await waitForApp(page);
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
});
test('sidebar has CC Agent nav items', async ({ page }) => {
await page.goto(BASE + '/');
await waitForApp(page);
const sidebar = page.locator('aside').first();
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
}
});
// Last test — sign out to release session
test('sign-out completes', async ({ page }) => {
await page.goto(BASE + '/');
await waitForApp(page);
const sidebar = page.locator('aside').first();
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
if (await accountArea.isVisible()) await accountArea.click();
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
await signOutBtn.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 5_000 });
await modal.getByRole('button', { name: /sign out/i }).click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
}
});
});
test.describe('Global — Supervisor', () => {
test.beforeEach(async ({ page }) => {
await page.goto(BASE + '/login');
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('dr.ramesh@globalcare.com');
await page.locator('input[type="password"]').first().fill('Global@123');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
await waitForApp(page);
});
test('landing page loads', async ({ page }) => {
await expect(page.locator('aside').first()).toBeVisible();
});
test('Patients page loads', async ({ page }) => {
await page.goto(BASE + '/patients');
await waitForApp(page);
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
await expect(search.first()).toBeVisible();
});
test('Appointments page loads', async ({ page }) => {
await page.goto(BASE + '/appointments');
await waitForApp(page);
await expect(
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Campaigns page loads', async ({ page }) => {
await page.goto(BASE + '/campaigns');
await waitForApp(page);
await expect(
page.locator('text=/Campaign|No campaign/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Settings page loads', async ({ page }) => {
await page.goto(BASE + '/settings');
await waitForApp(page);
await expect(
page.locator('text=/Settings|Configuration/i').first(),
).toBeVisible({ timeout: 10_000 });
});
});

15
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Page, expect } from '@playwright/test';
export async function waitForApp(page: Page) {
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(300);
}
export async function loginAs(page: Page, email: string, password: string) {
await page.goto('/login');
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill(email);
await page.locator('input[type="password"]').first().fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
await waitForApp(page);
}

View File

@@ -0,0 +1,121 @@
/**
* Supervisor / Admin — happy-path smoke tests.
*
* Role: admin (supervisor@ramaiahcare.com)
* Landing: / → Dashboard
* Pages: Dashboard, Team Performance, Live Monitor,
* Leads, Patients, Appointments, Call Log,
* Call Recordings, Missed Calls, Campaigns, Settings
*/
import { test, expect } from '@playwright/test';
import { loginAs, waitForApp } from './helpers';
const EMAIL = 'supervisor@ramaiahcare.com';
const PASSWORD = 'MrRamaiah@2026';
test.describe('Supervisor Smoke', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, EMAIL, PASSWORD);
});
test('lands on Dashboard after login', async ({ page }) => {
await expect(page.locator('aside').first()).toBeVisible();
// Verify we're authenticated and on the app
await expect(page).not.toHaveURL(/\/login/);
});
test('Team Performance loads', async ({ page }) => {
await page.goto('/team-performance');
await waitForApp(page);
await expect(
page.locator('text=/Team|Performance|Agent|No data/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Live Call Monitor loads', async ({ page }) => {
await page.goto('/live-monitor');
await waitForApp(page);
await expect(
page.locator('text=/Live|Monitor|Active|No active/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Leads page loads', async ({ page }) => {
await page.goto('/leads');
await waitForApp(page);
await expect(
page.locator('text=/Lead|No leads/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Patients page loads', async ({ page }) => {
await page.goto('/patients');
await waitForApp(page);
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
await expect(search.first()).toBeVisible();
});
test('Appointments page loads', async ({ page }) => {
await page.goto('/appointments');
await waitForApp(page);
await expect(
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Call Log page loads', async ({ page }) => {
await page.goto('/call-history');
await waitForApp(page);
await expect(page.locator('text="Call History"').first()).toBeVisible();
});
test('Call Recordings page loads', async ({ page }) => {
await page.goto('/call-recordings');
await waitForApp(page);
await expect(
page.locator('text=/Recording|No recording/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Missed Calls page loads', async ({ page }) => {
await page.goto('/missed-calls');
await waitForApp(page);
await expect(
page.locator('text=/Missed|No missed/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Campaigns page loads', async ({ page }) => {
await page.goto('/campaigns');
await waitForApp(page);
await expect(
page.locator('text=/Campaign|No campaign/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('Settings page loads', async ({ page }) => {
await page.goto('/settings');
await waitForApp(page);
await expect(
page.locator('text=/Settings|Configuration/i').first(),
).toBeVisible({ timeout: 10_000 });
});
test('sidebar has expected nav items', async ({ page }) => {
const sidebar = page.locator('aside').first();
// Check key items — exact labels depend on the role the sidecar assigns
for (const item of ['Patients', 'Appointments', 'Campaigns']) {
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
}
});
});

View File

@@ -1,41 +0,0 @@
// @ts-check
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
// React Hooks — enforce rules of hooks and exhaustive deps
...reactHooks.configs.recommended.rules,
// React Refresh — warn on non-component exports (Vite HMR)
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// TypeScript
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
// General
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-duplicate-imports': 'error',
},
}
);

64
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"tailwindcss-react-aria-components": "^2.0.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",
@@ -1077,6 +1078,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@react-aria/autocomplete": {
"version": "3.0.0-rc.6",
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
@@ -5471,6 +5488,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",

View File

@@ -43,6 +43,7 @@
"tailwindcss-react-aria-components": "^2.0.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",

65
playwright.config.ts Normal file
View File

@@ -0,0 +1,65 @@
import { defineConfig } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: './e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
retries: 1,
workers: 1,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'https://ramaiah.engage.healix360.net',
headless: true,
screenshot: 'on',
trace: 'on-first-retry',
actionTimeout: 8_000,
navigationTimeout: 15_000,
},
projects: [
// Login tests run first — fresh browser, no saved auth
{
name: 'login',
testMatch: /agent-login\.spec\.ts/,
use: { browserName: 'chromium' },
},
// Auth setup — saves CC agent session for reuse
{
name: 'agent-setup',
testMatch: /auth\.setup\.ts/,
},
// CC Agent feature tests — reuse saved auth
{
name: 'cc-agent',
dependencies: ['agent-setup'],
use: {
storageState: path.join(__dirname, 'e2e/.auth/agent.json'),
browserName: 'chromium',
},
testMatch: /agent-smoke\.spec\.ts/,
},
// Supervisor tests — logs in fresh each run
{
name: 'supervisor',
testMatch: /supervisor-smoke\.spec\.ts/,
use: { browserName: 'chromium' },
},
// Global Hospital — auth setup + smoke tests
{
name: 'global-setup',
testMatch: /global-setup\.ts/,
},
{
name: 'global',
dependencies: ['global-setup'],
testMatch: /global-smoke\.spec\.ts/,
use: {
storageState: path.join(__dirname, 'e2e/.auth/global-agent.json'),
browserName: 'chromium',
},
},
],
});

4641
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +0,0 @@
/**
* fix-duplicate-imports.mjs
*
* Merges duplicate import statements from the same module across all TypeScript
* source files in the project. Run this whenever `npm run lint` reports
* `no-duplicate-imports` errors.
*
* Usage:
* node scripts/fix-duplicate-imports.mjs
*
* Handles:
* import type { A, B } from 'module' — type-only imports
* import Default from 'module' — default imports
* import Default, { A, B } from 'mod' — mixed default + named
* import { A, B } from 'module' — named imports
* import {\n A,\n B\n} from 'mod' — multi-line named imports
*
* When merging a `import type` with a value import from the same module,
* type-only specifiers are inlined as `type Name` in the merged statement.
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
import { extname, join, resolve } from "path";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const projectRoot = resolve(new URL(".", import.meta.url).pathname, "..");
const srcDir = join(projectRoot, "src");
const extensions = new Set([".ts", ".tsx"]);
// ---------------------------------------------------------------------------
// File discovery — recursively find all TS/TSX files under src/
// ---------------------------------------------------------------------------
function findFiles(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...findFiles(full));
} else if (extensions.has(extname(entry))) {
results.push(full);
}
}
return results;
}
// ---------------------------------------------------------------------------
// Import extraction
// ---------------------------------------------------------------------------
/**
* Extracts all top-level import statements from file content.
* Returns array of { start, end, raw, module, isType, defaultImport, namedImports }
* where start/end are character positions in the content.
*/
function extractImports(content) {
const results = [];
const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm;
let match;
while ((match = importRe.exec(content)) !== null) {
const raw = match[0];
const specifierPart = match[1];
const moduleName = match[2];
const isType = /^type\s+/.test(specifierPart.trimStart());
const cleanSpec = specifierPart.replace(/^type\s+/, "").trim();
let defaultImport = null;
let namedStr = null;
const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/);
if (namespaceMatch) {
defaultImport = `* as ${namespaceMatch[1]}`;
} else {
const braceIdx = cleanSpec.indexOf("{");
if (braceIdx === -1) {
const def = cleanSpec.replace(/,$/, "").trim();
if (def) defaultImport = def;
} else {
const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim();
if (beforeBrace) defaultImport = beforeBrace;
const closeBrace = cleanSpec.lastIndexOf("}");
namedStr = cleanSpec.slice(braceIdx + 1, closeBrace);
}
}
const namedImports = namedStr
? namedStr.split(",").map((s) => s.trim()).filter(Boolean)
: [];
results.push({
start: match.index,
end: match.index + raw.length,
raw,
module: moduleName,
isType,
defaultImport,
namedImports,
});
}
return results;
}
// ---------------------------------------------------------------------------
// Import merging
// ---------------------------------------------------------------------------
/**
* Build a single merged import statement from multiple imports of the same module.
*
* - All type imports → merged `import type { ... }`
* - Mixed type + value → merged value import with inline `type Name` specifiers
*/
function buildMergedImport(moduleName, importList) {
const allType = importList.every((i) => i.isType);
if (allType) {
const allNamed = new Set(importList.flatMap((i) => i.namedImports));
const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null;
const parts = [];
if (defaultImport) parts.push(defaultImport);
if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`);
if (parts.length === 0) return `import type "${moduleName}";`;
return `import type ${parts.join(", ")} from "${moduleName}";`;
}
// Mixed: collect value default, type-only named, value named separately
let valueDefault = null;
const typeNamed = new Set();
const valueNamed = new Set();
for (const imp of importList) {
if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport;
for (const n of imp.namedImports) {
(imp.isType ? typeNamed : valueNamed).add(n);
}
}
// Build named specifiers: `type X` for type-only, plain for value
const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`);
const valueSpecifiers = [...valueNamed];
const namedParts = [...typeSpecifiers, ...valueSpecifiers];
const parts = [];
if (valueDefault) parts.push(valueDefault);
if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`);
if (parts.length === 0) return `import "${moduleName}";`;
return `import ${parts.join(", ")} from "${moduleName}";`;
}
// ---------------------------------------------------------------------------
// File fixer
// ---------------------------------------------------------------------------
function fixFile(filePath) {
let content = readFileSync(filePath, "utf-8");
const imports = extractImports(content);
if (imports.length === 0) return null;
// Group by module
const byModule = new Map();
for (const imp of imports) {
if (!byModule.has(imp.module)) byModule.set(imp.module, []);
byModule.get(imp.module).push(imp);
}
if (![...byModule.values()].some((v) => v.length > 1)) return null;
// Build merged text for each module
const mergedMap = new Map();
for (const [mod, imps] of byModule) {
mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps));
}
// Build replacement list (process in reverse order to preserve character positions)
imports.sort((a, b) => a.start - b.start);
const placedModules = new Set();
const replacements = [];
for (const imp of imports) {
if (!placedModules.has(imp.module)) {
replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) });
placedModules.add(imp.module);
} else {
// Remove duplicate, including its trailing newline
const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end;
replacements.push({ start: imp.start, end, replacement: "" });
}
}
replacements.sort((a, b) => b.start - a.start);
for (const { start, end, replacement } of replacements) {
content = content.slice(0, start) + replacement + content.slice(end);
}
return content;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const files = findFiles(srcDir);
let fixedCount = 0;
for (const filePath of files) {
try {
const original = readFileSync(filePath, "utf-8");
const fixed = fixFile(filePath);
if (fixed && fixed !== original) {
writeFileSync(filePath, fixed, "utf-8");
const rel = filePath.replace(projectRoot + "/", "");
console.log(`Fixed: ${rel}`);
fixedCount++;
}
} catch (err) {
const rel = filePath.replace(projectRoot + "/", "");
console.error(`Error: ${rel}${err.message}`);
}
}
console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);

View File

@@ -1,23 +1,21 @@
/**
* Helix Engage — Platform Data Seeder
* Creates 5 patient stories + 5 doctors with fully linked records.
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
* Creates 2 clinics, 5 doctors with multi-clinic visit slots,
* 3 patient stories with fully linked records (campaigns, leads,
* calls, appointments, follow-ups, lead activities).
*
* Platform field mapping (SDK name → platform name):
* Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions,
* clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated
* Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted,
* lastContactedAt→lastContacted, landingPageUrl→landingPage
* Call: callDirection→direction, durationSeconds→durationSec
* Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room
* FollowUp: followUpType→typeCustom, followUpStatus→status
* Patient: address→addressCustom
* Doctor: isActive→active, branch→branchClinic
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
* Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain)
*
* Schema alignment (2026-04-10):
* - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity
* - Doctor.portalUserId omitted (workspace member IDs are per-deployment)
* - Clinic entity added (needed for visit slot FK)
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
*/
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
const SUB = 'fortytwo-dev';
const SUB = process.env.SEED_SUB ?? 'fortytwo-dev';
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
let token = '';
@@ -51,28 +49,172 @@ async function mk(entity: string, data: any): Promise<string> {
return d[`create${cap}`].id;
}
// Create a workspace member (user account) and return its workspace member id.
// Uses signUpInWorkspace + updateWorkspaceMember for name + updateWorkspaceMemberRole.
// The invite hash and role IDs are fetched once and cached.
let _inviteHash = '';
let _wsId = '';
const _roleIds: Record<string, string> = {};
async function ensureWorkspaceContext() {
if (_wsId) return;
const ws = await gql('{ currentWorkspace { id inviteHash } }');
_wsId = ws.currentWorkspace.id;
_inviteHash = ws.currentWorkspace.inviteHash;
const roles = await gql('{ getRoles { id label } }');
for (const r of roles.getRoles) _roleIds[r.label] = r.id;
}
async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise<string> {
await ensureWorkspaceContext();
// Check if already exists
const existing = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
const found = existing.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
if (found) {
console.log(` (exists) ${email}${found.node.id}`);
return found.node.id;
}
// Create the user + link to workspace
await gql(
`mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
signUpInWorkspace(email: $email, password: $password, workspaceId: $workspaceId, workspaceInviteHash: $workspaceInviteHash) { workspace { id } }
}`,
{ email, password, workspaceId: _wsId, workspaceInviteHash: _inviteHash },
);
// Find the new member id
const members = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
const member = members.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
if (!member) throw new Error(`Could not find workspace member for ${email}`);
const memberId = member.node.id;
// Set their display name
await gql(
`mutation($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`,
{ id: memberId, data: { name: { firstName, lastName } } },
);
// Assign role if specified
if (roleName && _roleIds[roleName]) {
await gql(
`mutation($wm: UUID!, $role: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $wm, roleId: $role) { id } }`,
{ wm: memberId, role: _roleIds[roleName] },
);
}
return memberId;
}
async function clearAll() {
// Delete in reverse dependency order
const entities = ['followUp', 'leadActivity', 'call', 'appointment', 'lead', 'patient', 'doctorVisitSlot', 'doctor', 'campaign', 'clinic'];
for (const entity of entities) {
const cap = entity[0].toUpperCase() + entity.slice(1);
try {
const data = await gql(`{ ${entity}s(first: 100) { edges { node { id } } } }`);
const ids: string[] = data[`${entity}s`].edges.map((e: any) => e.node.id);
if (ids.length === 0) { console.log(` ${cap}: 0 records`); continue; }
for (const id of ids) {
await gql(`mutation { delete${cap}(id: "${id}") { id } }`);
}
console.log(` ${cap}: deleted ${ids.length}`);
} catch (err: any) {
console.log(` ${cap}: skip (${err.message?.slice(0, 60)})`);
}
}
}
async function main() {
console.log('🌱 Seeding Helix Engage demo data...\n');
await auth();
console.log('✅ Auth OK\n');
// Workspace member IDs — switch based on target platform
const WM = GQL.includes('srv1477139') ? {
drSharma: '107efa70-fd32-4819-8936-994197c6ada1',
drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209',
drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c',
drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b',
drSingh: 'b2a00dd2-5bb5-4c29-8fb1-70a681193a4c',
} : {
drSharma: '251e9b32-3a83-4f3c-a904-fad7e8b840c3',
drPatel: '2b1bbf20-3838-434f-9fe9-b98436362230',
drKumar: '16109622-9b13-4682-b327-eb611ffa8338',
drReddy: '478a9ccb-d231-48fb-a740-0228d3c9325b',
drSingh: 'b854b55b-7302-4981-8dfc-bea516abdc86',
};
// Clean slate — remove all existing entity data (not users)
console.log('🧹 Clearing existing data...');
await clearAll();
console.log('');
await auth();
// ═══════════════════════════════════════════
// DOCTORS (linked to workspace members)
// CLINICS (needed for doctor visit slots)
// ═══════════════════════════════════════════
console.log('🏥 Clinics');
const clinicKor = await mk('clinic', {
name: 'Global Hospital — Koramangala',
clinicName: 'Global Hospital — Koramangala',
status: 'ACTIVE',
opensAt: '08:00', closesAt: '20:00',
openMonday: true, openTuesday: true, openWednesday: true,
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
phone: { primaryPhoneNumber: '8041763265', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'Koramangala 4th Block' },
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
});
console.log(` Koramangala: ${clinicKor}`);
const clinicWf = await mk('clinic', {
name: 'Global Hospital — Whitefield',
clinicName: 'Global Hospital — Whitefield',
status: 'ACTIVE',
opensAt: '09:00', closesAt: '18:00',
openMonday: true, openTuesday: true, openWednesday: true,
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
phone: { primaryPhoneNumber: '8041763400', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'ITPL Main Road, Whitefield' },
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
});
console.log(` Whitefield: ${clinicWf}\n`);
await auth();
// ═══════════════════════════════════════════
// CALL CENTER & MARKETING STAFF
//
// CC agents (HelixEngage User role) handle inbound/outbound calls.
// Marketing executives and supervisors use HelixEngage Supervisor role.
// Email domain uses globalcare.com to match the deployment.
// ═══════════════════════════════════════════
console.log('📞 Call center & marketing staff');
const wmRekha = await mkMember('rekha.cc@globalcare.com', 'Global@123', 'Rekha', 'Nair', 'HelixEngage User');
console.log(` Rekha (CC Agent): ${wmRekha}`);
const wmGanesh = await mkMember('ganesh.cc@globalcare.com', 'Global@123', 'Ganesh', 'Iyer', 'HelixEngage User');
console.log(` Ganesh (CC Agent): ${wmGanesh}`);
const wmSanjay = await mkMember('sanjay.marketing@globalcare.com', 'Global@123', 'Sanjay', 'Verma', 'HelixEngage Supervisor');
console.log(` Sanjay (Marketing): ${wmSanjay}`);
const wmRamesh = await mkMember('dr.ramesh@globalcare.com', 'Global@123', 'Ramesh', 'Gupta', 'HelixEngage Supervisor');
console.log(` Dr. Ramesh (Supervisor): ${wmRamesh}\n`);
await auth();
// ═══════════════════════════════════════════
// DOCTOR WORKSPACE MEMBERS
//
// Each doctor gets a real platform login so they can access the
// portal. Created via signUpInWorkspace, then linked to the Doctor
// entity via portalUserId. Email domain matches the deployment.
// ═══════════════════════════════════════════
console.log('👤 Doctor workspace members (role: HelixEngage Manager)');
const wmSharma = await mkMember('dr.sharma@globalcare.com', 'DrSharma@2026', 'Arun', 'Sharma', 'HelixEngage Manager');
console.log(` Dr. Sharma member: ${wmSharma}`);
const wmPatel = await mkMember('dr.patel@globalcare.com', 'DrPatel@2026', 'Meena', 'Patel', 'HelixEngage Manager');
console.log(` Dr. Patel member: ${wmPatel}`);
const wmKumar = await mkMember('dr.kumar@globalcare.com', 'DrKumar@2026', 'Rajesh', 'Kumar', 'HelixEngage Manager');
console.log(` Dr. Kumar member: ${wmKumar}`);
const wmReddy = await mkMember('dr.reddy@globalcare.com', 'DrReddy@2026', 'Lakshmi', 'Reddy', 'HelixEngage Manager');
console.log(` Dr. Reddy member: ${wmReddy}`);
const wmSingh = await mkMember('dr.singh@globalcare.com', 'DrSingh@2026', 'Harpreet', 'Singh', 'HelixEngage Manager');
console.log(` Dr. Singh member: ${wmSingh}\n`);
await auth();
// ═══════════════════════════════════════════
// DOCTORS (linked to workspace members via portalUserId)
//
// visitingHours was removed — multi-clinic schedules now live
// on DoctorVisitSlot (seeded below).
// ═══════════════════════════════════════════
console.log('👨‍⚕️ Doctors');
const drSharma = await mk('doctor', {
@@ -82,16 +224,15 @@ async function main() {
specialty: 'Interventional Cardiology',
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
yearsOfExperience: 18,
visitingHours: 'Mon/Wed/Fri 10:00 AM 1:00 PM',
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.sharma@globalhospital.com' },
email: { primaryEmail: 'dr.sharma@globalcare.com' },
registrationNumber: 'KMC-45672',
active: true,
portalUserId: WM.drSharma,
portalUserId: wmSharma,
});
console.log(` Dr. Sharma (Cardiology, WM: ${WM.drSharma}): ${drSharma}`);
console.log(` Dr. Sharma (Cardiology ${wmSharma}): ${drSharma}`);
const drPatel = await mk('doctor', {
name: 'Dr. Meena Patel',
@@ -100,16 +241,15 @@ async function main() {
specialty: 'Reproductive Medicine & IVF',
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
yearsOfExperience: 15,
visitingHours: 'Tue/Thu/Sat 9:00 AM 12:00 PM',
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.patel@globalhospital.com' },
email: { primaryEmail: 'dr.patel@globalcare.com' },
registrationNumber: 'KMC-38291',
active: true,
portalUserId: WM.drPatel,
portalUserId: wmPatel,
});
console.log(` Dr. Patel (Gynecology/IVF, WM: ${WM.drPatel}): ${drPatel}`);
console.log(` Dr. Patel (Gynecology/IVF ${wmPatel}): ${drPatel}`);
const drKumar = await mk('doctor', {
name: 'Dr. Rajesh Kumar',
@@ -118,16 +258,15 @@ async function main() {
specialty: 'Joint Replacement & Sports Medicine',
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
yearsOfExperience: 12,
visitingHours: 'MonFri 2:00 PM 5:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.kumar@globalhospital.com' },
email: { primaryEmail: 'dr.kumar@globalcare.com' },
registrationNumber: 'KMC-51003',
active: true,
portalUserId: WM.drKumar,
portalUserId: wmKumar,
});
console.log(` Dr. Kumar (Orthopedics, WM: ${WM.drKumar}): ${drKumar}`);
console.log(` Dr. Kumar (Orthopedics ${wmKumar}): ${drKumar}`);
const drReddy = await mk('doctor', {
name: 'Dr. Lakshmi Reddy',
@@ -136,16 +275,15 @@ async function main() {
specialty: 'Internal Medicine & Preventive Health',
qualifications: 'MBBS, MD (General Medicine)',
yearsOfExperience: 20,
visitingHours: 'MonSat 9:00 AM 6:00 PM',
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.reddy@globalhospital.com' },
email: { primaryEmail: 'dr.reddy@globalcare.com' },
registrationNumber: 'KMC-22145',
active: true,
portalUserId: WM.drReddy,
portalUserId: wmReddy,
});
console.log(` Dr. Reddy (General Medicine, WM: ${WM.drReddy}): ${drReddy}`);
console.log(` Dr. Reddy (General Medicine ${wmReddy}): ${drReddy}`);
const drSingh = await mk('doctor', {
name: 'Dr. Harpreet Singh',
@@ -154,16 +292,57 @@ async function main() {
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
qualifications: 'MBBS, MS (ENT), DNB',
yearsOfExperience: 10,
visitingHours: 'Mon/Wed/Fri 11:00 AM 3:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.singh@globalhospital.com' },
email: { primaryEmail: 'dr.singh@globalcare.com' },
registrationNumber: 'KMC-60782',
active: true,
portalUserId: WM.drSingh,
portalUserId: wmSingh,
});
console.log(` Dr. Singh (ENT, WM: ${WM.drSingh}): ${drSingh}\n`);
console.log(` Dr. Singh (ENT ${wmSingh}): ${drSingh}\n`);
await auth();
// ═══════════════════════════════════════════
// DOCTOR VISIT SLOTS (weekly schedule per doctor × clinic)
// ═══════════════════════════════════════════
console.log('📅 Visit Slots');
const slots: Array<{ doc: string; docName: string; clinic: string; clinicName: string; day: string; start: string; end: string }> = [
// Dr. Sharma — Koramangala Mon/Wed/Fri 10:0013:00
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '10:00', end: '13:00' },
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '10:00', end: '13:00' },
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '10:00', end: '13:00' },
// Dr. Patel — Whitefield Tue/Thu/Sat 9:0012:00
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '09:00', end: '12:00' },
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '09:00', end: '12:00' },
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '09:00', end: '12:00' },
// Dr. Kumar — Koramangala MonFri 14:0017:00
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '14:00', end: '17:00' },
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'TUESDAY', start: '14:00', end: '17:00' },
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '14:00', end: '17:00' },
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'THURSDAY', start: '14:00', end: '17:00' },
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '14:00', end: '17:00' },
// Dr. Reddy — both clinics MonSat
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '09:00', end: '13:00' },
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '09:00', end: '13:00' },
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '09:00', end: '13:00' },
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '14:00', end: '18:00' },
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '14:00', end: '18:00' },
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '14:00', end: '18:00' },
// Dr. Singh — Whitefield Mon/Wed/Fri 11:0015:00
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'MONDAY', start: '11:00', end: '15:00' },
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'WEDNESDAY', start: '11:00', end: '15:00' },
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'FRIDAY', start: '11:00', end: '15:00' },
];
for (const s of slots) {
await mk('doctorVisitSlot', {
name: `Dr. ${s.docName}${s.day} ${s.start}${s.end} (${s.clinicName})`,
doctorId: s.doc, clinicId: s.clinic,
dayOfWeek: s.day, startTime: s.start, endTime: s.end,
});
}
console.log(` ${slots.length} visit slots created\n`);
await auth();
@@ -406,9 +585,10 @@ async function main() {
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
console.log('🎉 Seed complete!');
console.log(' 5 doctors · 3 campaigns · 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
console.log(' 2 clinics · 5 doctors · 20 visit slots · 3 campaigns');
console.log(' 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
console.log(' All appointments linked to doctor entities');
console.log(' Doctors linked to clinics via visit slots (multi-clinic schedule)');
}
main().catch(e => { console.error('💥', e.message); process.exit(1); });

117
scripts/seed-ramaiah.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Helix Engage — Ramaiah Hospital Data Seeder
*
* Seeds clinic + 195 doctors from scraped website data.
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah.ts
*/
import { readFileSync } from 'fs';
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
const SUB = process.env.SEED_SUB ?? 'ramaiah';
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
const DATA_FILE = process.env.SEED_DATA ?? '/tmp/ramaiah-seed-data.json';
let token = '';
async function gql(query: string, variables?: any) {
const h: Record<string, string> = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB };
if (token) h['Authorization'] = `Bearer ${token}`;
const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) });
const d: any = await r.json();
if (d.errors) { console.error('❌', d.errors[0].message); throw new Error(d.errors[0].message); }
return d.data;
}
async function auth() {
const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`);
const lt = d1.getLoginTokenFromCredentials.loginToken.token;
const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`);
token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
}
async function mk(entity: string, data: any): Promise<string> {
const cap = entity[0].toUpperCase() + entity.slice(1);
const d = await gql(`mutation($data: ${cap}CreateInput!) { create${cap}(data: $data) { id } }`, { data });
return d[`create${cap}`].id;
}
async function main() {
console.log('🌱 Seeding Ramaiah Hospital data...\n');
const raw = JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
console.log(`📁 Loaded ${raw.doctors.length} doctors, ${raw.departments.length} departments\n`);
await auth();
console.log('✅ Auth OK\n');
// Clinic
console.log('🏥 Clinic');
const clinicId = await mk('clinic', {
name: raw.clinic.name,
clinicName: raw.clinic.name,
status: 'ACTIVE',
opensAt: '08:00',
closesAt: '20:00',
openMonday: true, openTuesday: true, openWednesday: true,
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
phone: {
primaryPhoneNumber: raw.clinic.phone?.replace(/[^0-9]/g, '').slice(-10) ?? '',
primaryPhoneCallingCode: '+91',
primaryPhoneCountryCode: 'IN',
},
addressCustom: {
addressStreet1: raw.clinic.address?.split(',')[0] ?? 'New BEL Road',
addressCity: raw.clinic.city ?? 'Bangalore',
addressState: raw.clinic.state ?? 'Karnataka',
addressCountry: 'India',
addressPostcode: raw.clinic.pincode ?? '560054',
},
onlineBooking: true,
walkInAllowed: true,
});
console.log(` ${raw.clinic.name}: ${clinicId}\n`);
// Re-auth (long operation ahead)
await auth();
// Doctors — batch in groups of 20 with re-auth
console.log(`👨‍⚕️ Doctors (${raw.doctors.length})`);
let created = 0;
let failed = 0;
for (let i = 0; i < raw.doctors.length; i++) {
// Re-auth every 40 doctors (token may expire on long runs)
if (i > 0 && i % 40 === 0) {
await auth();
console.log(` (re-authed at ${i})`);
}
const doc = raw.doctors[i];
const firstName = doc.name.replace(/^Dr\.?\s*/i, '').split(' ')[0] ?? '';
const lastNameParts = doc.name.replace(/^Dr\.?\s*/i, '').split(' ').slice(1);
const lastName = lastNameParts.join(' ');
try {
await mk('doctor', {
name: doc.name,
fullName: { firstName, lastName },
department: doc.department ?? 'Other',
specialty: doc.designation ?? 'Consultant',
qualifications: doc.qualifications ?? '',
registrationNumber: '',
active: true,
});
created++;
if (created % 20 === 0) console.log(` ${created}/${raw.doctors.length} created...`);
} catch (err: any) {
failed++;
console.error(`${doc.name}: ${err.message?.slice(0, 80)}`);
}
}
console.log(`\n ✅ ${created} doctors created, ${failed} failed\n`);
console.log('🎉 Ramaiah seed complete!');
console.log(` 1 clinic · ${created} doctors · ${raw.departments.length} departments`);
}
main().catch(e => { console.error('💥', e.message); process.exit(1); });

View File

@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { Link } from "react-router";
import { formatCurrency } from "@/lib/format";
import type { Campaign } from "@/types/entities";
import { cx } from "@/utils/cx";
import { useMemo } from 'react';
import { Link } from 'react-router';
import { formatCurrency } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
interface CampaignRoiCardsProps {
campaigns: Campaign[];
@@ -33,9 +34,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
}, [campaigns]);
const getHealthColor = (rate: number): string => {
if (rate >= 0.1) return "bg-success-500";
if (rate >= 0.05) return "bg-warning-500";
return "bg-error-500";
if (rate >= 0.1) return 'bg-success-500';
if (rate >= 0.05) return 'bg-warning-500';
return 'bg-error-500';
};
return (
@@ -43,10 +44,17 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
<div className="flex gap-4 overflow-x-auto pb-1">
{sorted.map((campaign) => (
<div key={campaign.id} className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4">
<div
key={campaign.id}
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
>
<div className="flex items-center gap-2">
<span className={cx("size-2 shrink-0 rounded-full", getHealthColor(campaign.conversionRate))} />
<span className="truncate text-sm font-semibold text-primary">{campaign.campaignName}</span>
<span
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
/>
<span className="truncate text-sm font-semibold text-primary">
{campaign.campaignName}
</span>
</div>
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
@@ -56,14 +64,23 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
</div>
<div className="mt-2">
<span className="text-sm font-bold text-primary">{campaign.cac === Infinity ? "—" : formatCurrency(campaign.cac)}</span>
<span className="text-sm font-bold text-primary">
{campaign.cac === Infinity
? '—'
: formatCurrency(campaign.cac)}
</span>
<span className="ml-1 text-xs text-tertiary">CAC</span>
</div>
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }} />
<div
className="h-full rounded-full bg-brand-solid"
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
/>
</div>
<span className="mt-1 block text-xs text-quaternary">{Math.round(campaign.budgetProgress * 100)}% budget used</span>
<span className="mt-1 block text-xs text-quaternary">
{Math.round(campaign.budgetProgress * 100)}% budget used
</span>
</div>
))}
</div>

View File

@@ -1,37 +1,37 @@
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
import { cx } from "@/utils/cx";
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
interface IntegrationHealthProps {
sources: LeadIngestionSource[];
}
const statusBorderMap: Record<IntegrationStatus, string> = {
ACTIVE: "border-secondary",
WARNING: "border-warning",
ERROR: "border-error",
DISABLED: "border-secondary",
ACTIVE: 'border-secondary',
WARNING: 'border-warning',
ERROR: 'border-error',
DISABLED: 'border-secondary',
};
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
ACTIVE: "success",
WARNING: "warning",
ERROR: "error",
DISABLED: "gray",
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
ACTIVE: 'success',
WARNING: 'warning',
ERROR: 'error',
DISABLED: 'gray',
};
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
VALID: "success",
EXPIRING_SOON: "warning",
EXPIRED: "error",
NOT_CONFIGURED: "gray",
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
VALID: 'success',
EXPIRING_SOON: 'warning',
EXPIRED: 'error',
NOT_CONFIGURED: 'gray',
};
function formatRelativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return "just now";
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
@@ -48,35 +48,60 @@ export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{sources.map((source) => {
const status = source.integrationStatus ?? "DISABLED";
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
const showAuthBadge = authStatus !== "VALID";
const status = source.integrationStatus ?? 'DISABLED';
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
const showAuthBadge = authStatus !== 'VALID';
return (
<div key={source.id} className={cx("rounded-xl border bg-primary p-4", statusBorderMap[status])}>
<div
key={source.id}
className={cx(
'rounded-xl border bg-primary p-4',
statusBorderMap[status],
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
<span className="text-sm font-semibold text-primary">
{source.sourceName}
</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={statusBadgeColorMap[status]}
>
{status}
</BadgeWithDot>
</div>
<p className="mt-2 text-xs text-tertiary">{source.leadsReceivedLast24h ?? 0} leads in 24h</p>
<p className="mt-2 text-xs text-tertiary">
{source.leadsReceivedLast24h ?? 0} leads in 24h
</p>
{source.lastSuccessfulSyncAt && (
<p className="mt-0.5 text-xs text-quaternary">Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}</p>
<p className="mt-0.5 text-xs text-quaternary">
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
</p>
)}
{showAuthBadge && (
<div className="mt-2">
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
Auth: {authStatus.replace(/_/g, " ")}
<BadgeWithDot
size="sm"
type="pill-color"
color={authBadgeColorMap[authStatus]}
>
Auth: {authStatus.replace(/_/g, ' ')}
</BadgeWithDot>
</div>
)}
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
<p
className={cx(
'mt-2 text-xs',
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
)}
>
{source.lastErrorMessage}
</p>
)}

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
import { useMemo } from 'react';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
interface LeadFunnelProps {
leads: Lead[];
@@ -16,24 +17,28 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
const stages = useMemo((): FunnelStage[] => {
const total = leads.length;
const contacted = leads.filter(
(lead) =>
lead.leadStatus === "CONTACTED" ||
lead.leadStatus === "QUALIFIED" ||
lead.leadStatus === "NURTURING" ||
lead.leadStatus === "APPOINTMENT_SET" ||
lead.leadStatus === "CONVERTED",
const contacted = leads.filter((lead) =>
lead.leadStatus === 'CONTACTED' ||
lead.leadStatus === 'QUALIFIED' ||
lead.leadStatus === 'NURTURING' ||
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
).length;
const appointmentSet = leads.filter((lead) => lead.leadStatus === "APPOINTMENT_SET" || lead.leadStatus === "CONVERTED").length;
const appointmentSet = leads.filter((lead) =>
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
).length;
const converted = leads.filter((lead) => lead.leadStatus === "CONVERTED").length;
const converted = leads.filter((lead) =>
lead.leadStatus === 'CONVERTED',
).length;
return [
{ label: "Generated", count: total, color: "bg-brand-600" },
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
{ label: "Converted", count: converted, color: "bg-success-500" },
{ label: 'Generated', count: total, color: 'bg-brand-600' },
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
{ label: 'Converted', count: converted, color: 'bg-success-500' },
];
}, [leads]);
@@ -47,7 +52,10 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
{stages.map((stage, index) => {
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
const previousCount = index > 0 ? stages[index - 1].count : null;
const conversionRate = previousCount !== null && previousCount > 0 ? ((stage.count / previousCount) * 100).toFixed(0) : null;
const conversionRate =
previousCount !== null && previousCount > 0
? ((stage.count / previousCount) * 100).toFixed(0)
: null;
return (
<div key={stage.label} className="space-y-1">
@@ -55,11 +63,16 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
<span className="text-xs font-medium text-secondary">{stage.label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">{stage.count}</span>
{conversionRate !== null && <span className="text-xs text-tertiary">({conversionRate}%)</span>}
{conversionRate !== null && (
<span className="text-xs text-tertiary">({conversionRate}%)</span>
)}
</div>
</div>
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
<div className={cx("h-full rounded-md transition-all", stage.color)} style={{ width: `${Math.max(widthPercent, 2)}%` }} />
<div
className={cx('h-full rounded-md transition-all', stage.color)}
style={{ width: `${Math.max(widthPercent, 2)}%` }}
/>
</div>
</div>
);

View File

@@ -1,8 +1,10 @@
import { type FC, useMemo } from "react";
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
import type { FC } from 'react';
import { useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
@@ -18,7 +20,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
const metrics = useMemo(() => {
const responseTimes: number[] = [];
let withinSla = 0;
const total = leads.length;
let total = leads.length;
for (const lead of leads) {
if (lead.createdAt && lead.firstContactedAt) {
@@ -34,40 +36,39 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
// Leads without firstContactedAt are counted as outside SLA
}
const avgHours = responseTimes.length > 0 ? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length : 0;
const avgHours =
responseTimes.length > 0
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
: 0;
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
return { avgHours, withinSla, total, slaPercent };
}, [leads]);
const getTargetStatus = (): {
icon: FC<{ className?: string }>;
label: string;
colorClass: string;
} => {
const getTargetStatus = (): { icon: FC<{ className?: string }>; label: string; colorClass: string } => {
const diff = metrics.avgHours - SLA_TARGET_HOURS;
if (diff <= 0) {
return {
icon: CheckCircle,
label: "Below target",
colorClass: "text-success-primary",
label: 'Below target',
colorClass: 'text-success-primary',
};
}
if (diff <= 0.5) {
return {
icon: AlertTriangle,
label: "Near target",
colorClass: "text-warning-primary",
label: 'Near target',
colorClass: 'text-warning-primary',
};
}
return {
icon: AlertCircle,
label: "Above target",
colorClass: "text-error-primary",
label: 'Above target',
colorClass: 'text-error-primary',
};
};
@@ -79,14 +80,18 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
<div className="mt-4 flex items-end gap-3">
<span className="text-display-sm font-bold text-primary">{metrics.avgHours.toFixed(1)}h</span>
<span className="text-display-sm font-bold text-primary">
{metrics.avgHours.toFixed(1)}h
</span>
<div className="mb-1 flex items-center gap-1">
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
<StatusIcon className={cx("size-4", status.colorClass)} />
<StatusIcon className={cx('size-4', status.colorClass)} />
</div>
</div>
<span className={cx("mt-1 block text-xs font-medium", status.colorClass)}>{status.label}</span>
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
{status.label}
</span>
<div className="mt-4">
<div className="flex items-center justify-between text-xs text-tertiary">
@@ -96,15 +101,18 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
<div
className={cx(
"h-full rounded-full transition-all",
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
'h-full rounded-full transition-all',
metrics.slaPercent >= 80
? 'bg-success-500'
: metrics.slaPercent >= 60
? 'bg-warning-500'
: 'bg-error-500',
)}
style={{ width: `${metrics.slaPercent}%` }}
/>
</div>
<span className="mt-1.5 block text-xs text-tertiary">
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}
%)
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%)
</span>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { Agent, Call, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
import { useMemo } from 'react';
import { Avatar } from '@/components/base/avatar/avatar';
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { Lead, Call, Agent } from '@/types/entities';
interface TeamScoreboardProps {
leads: Lead[];
@@ -24,14 +25,16 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
const agentCalls = calls.filter((call) => call.agentName === agentName);
const callsMade = agentCalls.length;
const appointmentsBooked = agentCalls.filter((call) => call.disposition === "APPOINTMENT_BOOKED").length;
const appointmentsBooked = agentCalls.filter(
(call) => call.disposition === 'APPOINTMENT_BOOKED',
).length;
return { agent, leadsProcessed, callsMade, appointmentsBooked };
});
}, [leads, calls, agents]);
const bestPerformerId = useMemo(() => {
let bestId = "";
let bestId = '';
let maxAppointments = -1;
for (const stat of agentStats) {
@@ -53,19 +56,29 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
<div
key={agent.id}
className={cx(
"flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5",
isBest && "ring-2 ring-success-600",
'flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5',
isBest && 'ring-2 ring-success-600',
)}
>
<div className="flex items-center gap-3">
<Avatar initials={agent.initials ?? undefined} size="md" status={agent.isOnShift ? "online" : "offline"} />
<Avatar
initials={agent.initials ?? undefined}
size="md"
status={agent.isOnShift ? 'online' : 'offline'}
/>
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold text-primary">{agent.name}</span>
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
{agent.isOnShift ? "On Shift" : "Off Shift"}
<BadgeWithDot
size="sm"
type="pill-color"
color={agent.isOnShift ? 'success' : 'gray'}
>
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
</BadgeWithDot>
</div>
{isBest && <span className="text-xs font-medium text-success-primary">Top</span>}
{isBest && (
<span className="text-xs font-medium text-success-primary">Top</span>
)}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -83,7 +96,9 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
</div>
<div className="flex flex-col">
<span className="text-xs text-tertiary">Avg Response</span>
<span className="text-lg font-bold text-primary">{agent.avgResponseHours ?? "—"}h</span>
<span className="text-lg font-bold text-primary">
{agent.avgResponseHours ?? '—'}h
</span>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import type { PropsWithChildren } from "react";
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
import {
Button as AriaButton,
Dialog as AriaDialog,

View File

@@ -6,12 +6,10 @@ export type NavItemType = {
/** URL to navigate to when the nav item is clicked. */
href?: string;
/** Icon component to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: FC<Record<string, any>>;
/** Badge to display. */
badge?: ReactNode;
/** List of sub-items to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
/** Whether this nav item is a divider. */
divider?: boolean;

View File

@@ -1,6 +1,11 @@
import type { FC, ReactNode } from "react";
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBell, faLifeRing, faMagnifyingGlass, faGear } from "@fortawesome/pro-duotone-svg-icons";
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
@@ -13,11 +18,6 @@ import { NavItemBase } from "./base-components/nav-item";
import { NavItemButton } from "./base-components/nav-item-button";
import { NavList } from "./base-components/nav-list";
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
type NavItem = {
/** Label text for the nav item. */
label: string;

View File

@@ -1,6 +1,9 @@
import { type FC, type ReactNode, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import type { FC, ReactNode } from "react";
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { AnimatePresence, motion } from "motion/react";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
@@ -11,8 +14,6 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationDualTierProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,6 +1,8 @@
import type { FC } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { MobileNavigationHeader } from "../base-components/mobile-header";
@@ -8,8 +10,6 @@ import { NavAccountCard } from "../base-components/nav-account-card";
import { NavList } from "../base-components/nav-list";
import type { NavItemDividerType, NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationSectionDividersProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,6 +1,8 @@
import type { FC, ReactNode } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { cx } from "@/utils/cx";
@@ -10,8 +12,6 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,6 +1,11 @@
import { type FC, useState } from "react";
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
import type { FC } from "react";
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLifeRing, faArrowRightFromBracket, faGear } from "@fortawesome/pro-duotone-svg-icons";
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
import { AnimatePresence, motion } from "motion/react";
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
@@ -17,10 +22,6 @@ import { NavItemButton } from "../base-components/nav-item-button";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
interface SidebarNavigationSlimProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,7 +1,12 @@
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
import {
Calendar as AriaCalendar,
CalendarContext as AriaCalendarContext,
@@ -9,10 +14,8 @@ import {
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
type CalendarProps as AriaCalendarProps,
CalendarStateContext as AriaCalendarStateContext,
Heading as AriaHeading,
type DateValue,
useSlottedContext,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
@@ -20,9 +23,6 @@ import { cx } from "@/utils/cx";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<DateValue | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -1,11 +1,6 @@
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
import {
CalendarCell as AriaCalendarCell,
type CalendarCellProps as AriaCalendarCellProps,
RangeCalendarContext,
useLocale,
useSlottedContext,
} from "react-aria-components";
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
import { cx } from "@/utils/cx";
interface CalendarCellProps extends AriaCalendarCellProps {

View File

@@ -1,7 +1,8 @@
import { DateInput as AriaDateInput, type DateInputProps as AriaDateInputProps, DateSegment as AriaDateSegment } from "react-aria-components";
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
import { cx } from "@/utils/cx";
type DateInputProps = Omit<AriaDateInputProps, "children">;
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
export const DateInput = (props: DateInputProps) => {
return (

View File

@@ -1,23 +1,17 @@
import type { FC } from "react";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLocalTimeZone, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
import { useDateFormatter } from "react-aria";
import {
DatePicker as AriaDatePicker,
type DatePickerProps as AriaDatePickerProps,
Dialog as AriaDialog,
Group as AriaGroup,
Popover as AriaPopover,
type DateValue,
} from "react-aria-components";
import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { Calendar } from "./calendar";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const highlightedDates = [today(getLocalTimeZone())];
interface DatePickerProps extends AriaDatePickerProps<DateValue> {

View File

@@ -1,26 +1,20 @@
import { type FC, useMemo, useState } from "react";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
import { useDateFormatter } from "react-aria";
import {
DateRangePicker as AriaDateRangePicker,
type DateRangePickerProps as AriaDateRangePickerProps,
Dialog as AriaDialog,
Group as AriaGroup,
Popover as AriaPopover,
type DateValue,
useLocale,
} from "react-aria-components";
import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { DateInput } from "./date-input";
import { RangeCalendar } from "./range-calendar";
import { RangePresetButton } from "./range-preset";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const now = today(getLocalTimeZone());
const highlightedDates = [today(getLocalTimeZone())];

View File

@@ -1,16 +1,19 @@
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import type { CalendarDate } from "@internationalized/date";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
import { useDateFormatter } from "react-aria";
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
import {
CalendarGrid as AriaCalendarGrid,
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
RangeCalendar as AriaRangeCalendar,
type RangeCalendarProps as AriaRangeCalendarProps,
type DateValue,
RangeCalendarContext,
RangeCalendarStateContext,
useSlottedContext,
@@ -20,9 +23,6 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -0,0 +1,73 @@
import { Select } from "@/components/base/select/select";
// 30-minute increments from 05:00 to 23:00 → 37 slots.
// Covers every realistic clinic opening / closing time.
// Values are 24-hour HH:MM strings — the same format stored on the
// Clinic + DoctorVisitSlot entities in the platform. Labels are
// 12-hour format with AM/PM for readability.
const TIME_SLOTS = Array.from({ length: 37 }, (_, i) => {
const totalMinutes = 5 * 60 + i * 30;
const hour = Math.floor(totalMinutes / 60);
const minute = totalMinutes % 60;
const h12 = hour % 12 || 12;
const period = hour >= 12 ? "PM" : "AM";
const id = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
const label = `${h12}:${String(minute).padStart(2, "0")} ${period}`;
return { id, label };
});
type TimePickerProps = {
/** Field label rendered above the select. */
label?: string;
/** Current value in 24-hour HH:MM format, or null when unset. */
value: string | null;
/** Called with the new HH:MM string when the user picks a slot. */
onChange: (value: string) => void;
isRequired?: boolean;
isDisabled?: boolean;
placeholder?: string;
};
// A minimal time-of-day picker built on top of the existing base
// Select component. Intentionally dropdown-based rather than the
// full DateTimePicker popover pattern from the reference demo —
// the clinic + doctor flows only need time, not date, and a
// dropdown is faster to use when the agent already knows the time.
//
// Use this for: clinic.opensAt / closesAt, doctorVisitSlot.startTime /
// endTime. For time-AND-date (appointment scheduling), stick with the
// existing DatePicker in the same directory.
export const TimePicker = ({
label,
value,
onChange,
isRequired,
isDisabled,
placeholder = "Select time",
}: TimePickerProps) => (
<Select
label={label}
placeholder={placeholder}
items={TIME_SLOTS}
selectedKey={value}
onSelectionChange={(key) => {
if (key !== null) onChange(String(key));
}}
isRequired={isRequired}
isDisabled={isDisabled}
>
{(slot) => <Select.Item id={slot.id} label={slot.label} />}
</Select>
);
// Format a 24-hour HH:MM string as a 12-hour display label (e.g.
// "09:30" → "9:30 AM"). Useful on list/detail pages that render
// stored clinic hours without re-mounting the picker.
export const formatTimeLabel = (hhmm: string | null | undefined): string => {
if (!hhmm) return "—";
const [h, m] = hhmm.split(":").map(Number);
if (Number.isNaN(h) || Number.isNaN(m)) return hhmm;
const h12 = h % 12 || 12;
const period = h >= 12 ? "PM" : "AM";
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
};

View File

@@ -0,0 +1,108 @@
import { cx } from "@/utils/cx";
// Keys match the Clinic entity's openMonday..openSunday fields
// directly — no translation layer needed when reading/writing the
// form value into GraphQL mutations.
export type DayKey =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
export type DaySelection = Record<DayKey, boolean>;
const DAYS: { key: DayKey; label: string }[] = [
{ key: "monday", label: "Mon" },
{ key: "tuesday", label: "Tue" },
{ key: "wednesday", label: "Wed" },
{ key: "thursday", label: "Thu" },
{ key: "friday", label: "Fri" },
{ key: "saturday", label: "Sat" },
{ key: "sunday", label: "Sun" },
];
type DaySelectorProps = {
/** Selected-state for each weekday. */
value: DaySelection;
/** Fires with the full updated selection whenever a pill is tapped. */
onChange: (value: DaySelection) => void;
/** Optional heading above the pills. */
label?: string;
/** Optional helper text below the pills. */
hint?: string;
};
// Seven tappable MonSun pills. Used on the Clinic form to pick which
// days the clinic is open, since the Clinic entity has seven separate
// BOOLEAN fields (openMonday..openSunday) — SDK has no MULTI_SELECT.
// Also reusable anywhere else we need a weekly-recurrence picker
// (future: follow-up schedules, on-call rotations).
export const DaySelector = ({ value, onChange, label, hint }: DaySelectorProps) => (
<div className="flex flex-col gap-2">
{label && (
<span className="text-sm font-medium text-secondary">{label}</span>
)}
<div className="flex flex-wrap gap-2">
{DAYS.map(({ key, label: dayLabel }) => {
const isSelected = !!value[key];
return (
<button
key={key}
type="button"
onClick={() => onChange({ ...value, [key]: !isSelected })}
className={cx(
"flex h-10 min-w-12 items-center justify-center rounded-full border px-4 text-sm font-semibold transition duration-100 ease-linear",
isSelected
? "border-brand bg-brand-solid text-white hover:bg-brand-solid_hover"
: "border-secondary bg-primary text-secondary hover:border-primary hover:bg-secondary_hover",
)}
aria-pressed={isSelected}
>
{dayLabel}
</button>
);
})}
</div>
{hint && <span className="text-xs text-tertiary">{hint}</span>}
</div>
);
// Helper factories — use these instead of spelling out the empty
// object literal everywhere.
export const emptyDaySelection = (): DaySelection => ({
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
});
// The default new-clinic state: MonSat open, Sun closed. Matches the
// typical Indian outpatient hospital schedule.
export const defaultDaySelection = (): DaySelection => ({
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: true,
sunday: false,
});
// Format a DaySelection as a compact human-readable string for list
// pages (e.g. "MonFri", "MonSat", "Mon Wed Fri"). Collapses
// consecutive selected days into ranges.
export const formatDaySelection = (sel: DaySelection): string => {
const openKeys = DAYS.filter((d) => sel[d.key]).map((d) => d.label);
if (openKeys.length === 0) return "Closed";
if (openKeys.length === 7) return "Every day";
// Monday-Friday, Monday-Saturday shorthand
if (openKeys.length === 5 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri") return "MonFri";
if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "MonSat";
return openKeys.join(" ");
};

View File

@@ -1,13 +1,15 @@
import { Children, type ComponentProps, type ComponentPropsWithRef, type FC, createContext, isValidElement, useContext } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { Children, createContext, isValidElement, useContext } from "react";
import { FileIcon } from "@untitledui/file-icons";
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
import { BackgroundPattern, type BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
import { cx } from "@/utils/cx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
import { cx } from "@/utils/cx";
interface RootContextProps {
size?: "sm" | "md" | "lg";

View File

@@ -1,7 +1,11 @@
import { type ComponentProps, type ComponentPropsWithRef, type FC, useId, useRef, useState } from "react";
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { useId, useRef, useState } from "react";
import type { FileIcon } from "@untitledui/file-icons";
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FileIcon, FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons";
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/base/buttons/button";
import { ButtonUtility } from "@/components/base/buttons/button-utility";
@@ -9,14 +13,11 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
/**
* Returns a human-readable file size.
* @param bytes - The size of the file in bytes.
* @returns A string representing the file size in a human-readable format.
*/
// eslint-disable-next-line react-refresh/only-export-components
export const getReadableFileSize = (bytes: number) => {
if (bytes === 0) return "0 KB";
@@ -387,7 +388,6 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
</ul>
);
// eslint-disable-next-line react-refresh/only-export-components
export const FileUpload = {
Root: FileUploadRoot,
List: FileUploadList,

View File

@@ -1,11 +1,5 @@
import {
Dialog as AriaDialog,
type DialogProps as AriaDialogProps,
DialogTrigger as AriaDialogTrigger,
Modal as AriaModal,
ModalOverlay as AriaModalOverlay,
type ModalOverlayProps as AriaModalOverlayProps,
} from "react-aria-components";
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import { cx } from "@/utils/cx";
export const DialogTrigger = AriaDialogTrigger;

View File

@@ -1,6 +1,10 @@
import type { FC } from "react";
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleExclamation, faCircleCheck, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { CloseButton } from "@/components/base/buttons/close-button";
@@ -8,10 +12,6 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
const iconMap = {
default: InfoCircle,
brand: InfoCircle,

View File

@@ -1,4 +1,5 @@
import { Toaster as SonnerToaster, type ToasterProps, useSonner } from "sonner";
import type { ToasterProps } from "sonner";
import { Toaster as SonnerToaster, useSonner } from "sonner";
import { cx } from "@/utils/cx";
export const DEFAULT_TOAST_POSITION = "bottom-right";

View File

@@ -1,15 +1,5 @@
import React, {
type CSSProperties,
type FC,
type HTMLAttributes,
type ReactNode,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useMemo,
} from "react";
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
type PaginationPage = {
/** The type of the pagination item. */
@@ -56,8 +46,9 @@ export interface PaginationRootProps {
onPageChange?: (page: number) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
const [pages, setPages] = useState<PaginationItemType[]>([]);
const createPaginationItems = useCallback((): PaginationItemType[] => {
const items: PaginationItemType[] = [];
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
@@ -159,7 +150,10 @@ const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children,
return items;
}, [total, siblingCount, page]);
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
useEffect(() => {
const paginationItems = createPaginationItems();
setPages(paginationItems);
}, [createPaginationItems]);
const onPageChangeHandler = (newPage: number) => {
onPageChange?.(newPage);
@@ -213,7 +207,6 @@ interface TriggerProps {
ariaLabel?: string;
}
// eslint-disable-next-line react-refresh/only-export-components
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -259,10 +252,8 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
);
};
// eslint-disable-next-line react-refresh/only-export-components
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
// eslint-disable-next-line react-refresh/only-export-components
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
interface PaginationItemRenderProps {
@@ -290,7 +281,6 @@ export interface PaginationItemProps {
asChild?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -353,7 +343,6 @@ interface PaginationEllipsisProps {
className?: string | (() => string);
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
const computedClassName = typeof className === "function" ? className() : className;
@@ -368,7 +357,6 @@ interface PaginationContextComponentProps {
children: (pagination: PaginationContextType) => ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
const context = useContext(PaginationContext);
if (!context) {

View File

@@ -1,5 +1,6 @@
import { cx } from "@/utils/cx";
import { Pagination, type PaginationRootProps } from "./pagination-base";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination dot. */

View File

@@ -1,5 +1,6 @@
import { cx } from "@/utils/cx";
import { Pagination, type PaginationRootProps } from "./pagination-base";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination line. */

View File

@@ -1,13 +1,10 @@
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
import {
Dialog as AriaDialog,
type DialogProps as AriaDialogProps,
DialogTrigger as AriaDialogTrigger,
Modal as AriaModal,
ModalOverlay as AriaModalOverlay,
type ModalOverlayProps as AriaModalOverlayProps,
type ModalRenderProps as AriaModalRenderProps,
import type {
DialogProps as AriaDialogProps,
ModalOverlayProps as AriaModalOverlayProps,
ModalRenderProps as AriaModalRenderProps,
} from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import { CloseButton } from "@/components/base/buttons/close-button";
import { cx } from "@/utils/cx";

View File

@@ -1,15 +1,7 @@
import { type ComponentPropsWithRef, Fragment, type ReactNode, createContext, useContext } from "react";
import {
Tab as AriaTab,
TabList as AriaTabList,
type TabListProps as AriaTabListProps,
TabPanel as AriaTabPanel,
type TabProps as AriaTabProps,
type TabRenderProps as AriaTabRenderProps,
Tabs as AriaTabs,
TabsContext,
useSlottedContext,
} from "react-aria-components";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { Fragment, createContext, useContext } from "react";
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
import type { BadgeColors } from "@/components/base/badges/badge-types";
import { Badge } from "@/components/base/badges/badges";
import { cx } from "@/utils/cx";

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { cx } from "@/utils/cx";
import { type AvatarProps } from "./avatar";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,6 +1,6 @@
import { type FC, type ReactNode, useState } from "react";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { cx } from "@/utils/cx";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,5 +1,5 @@
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
export * from "./avatar-add-button";
export * from "./avatar-company-icon";
export * from "./avatar-online-indicator";

View File

@@ -1,10 +1,11 @@
import { type FC, type ReactNode, isValidElement } from "react";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
import type { FC, ReactNode } from "react";
import { isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
type Size = "md" | "lg";
type Color = "brand" | "warning" | "error" | "gray" | "success";

View File

@@ -1,13 +1,13 @@
import type { FC, MouseEventHandler, ReactNode } from "react";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Dot } from "@/components/foundations/dot-icon";
import { cx } from "@/utils/cx";
import { type BadgeColors, type BadgeTypeToColorMap, type BadgeTypes, type FlagTypes, type IconComponentType, type Sizes, badgeTypes } from "./badge-types";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
import { Dot } from "@/components/foundations/dot-icon";
import { cx } from "@/utils/cx";
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
import { badgeTypes } from "./badge-types";
// eslint-disable-next-line react-refresh/only-export-components
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
gray: {
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",

View File

@@ -8,7 +8,6 @@ import {
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [

View File

@@ -1,11 +1,12 @@
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
import { isValidElement } from "react";
import type { Placement } from "react-aria";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { Tooltip } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = {
secondary:
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
@@ -62,20 +63,27 @@ export const ButtonUtility = ({
const href = "href" in otherProps ? otherProps.href : undefined;
const Component = href ? AriaLink : AriaButton;
const props = href
? {
...otherProps,
href: isDisabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
let props = {};
if (href) {
props = {
...otherProps,
href: isDisabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
}
const content = (
<Component

View File

@@ -1,9 +1,10 @@
import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
import React, { isValidElement } from "react";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [
@@ -191,16 +192,22 @@ export const Button = ({
noTextPadding = isLinkType || noTextPadding;
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
}
: {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
}
return (
<Component

View File

@@ -1,5 +1,5 @@
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -1,9 +1,9 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
@@ -96,20 +96,27 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
const Logo = logos[social];
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
}
return (
<Component

View File

@@ -1,20 +1,22 @@
import type { FC, RefAttributes } from "react";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import type {
ButtonProps as AriaButtonProps,
MenuItemProps as AriaMenuItemProps,
MenuProps as AriaMenuProps,
PopoverProps as AriaPopoverProps,
SeparatorProps as AriaSeparatorProps,
} from "react-aria-components";
import {
Button as AriaButton,
type ButtonProps as AriaButtonProps,
Header as AriaHeader,
Menu as AriaMenu,
MenuItem as AriaMenuItem,
type MenuItemProps as AriaMenuItemProps,
type MenuProps as AriaMenuProps,
MenuSection as AriaMenuSection,
MenuTrigger as AriaMenuTrigger,
Popover as AriaPopover,
type PopoverProps as AriaPopoverProps,
Separator as AriaSeparator,
type SeparatorProps as AriaSeparatorProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
@@ -29,7 +31,6 @@ interface DropdownItemProps extends AriaMenuItemProps {
icon?: FC<{ className?: string }>;
}
// eslint-disable-next-line react-refresh/only-export-components
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
if (unstyled) {
return <AriaMenuItem id={label} textValue={label} {...props} />;
@@ -88,9 +89,8 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
);
};
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
// eslint-disable-next-line react-refresh/only-export-components
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
return (
<AriaMenu
@@ -104,9 +104,8 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
);
};
type DropdownPopoverProps = AriaPopoverProps;
interface DropdownPopoverProps extends AriaPopoverProps {}
// eslint-disable-next-line react-refresh/only-export-components
const DropdownPopover = (props: DropdownPopoverProps) => {
return (
<AriaPopover
@@ -128,12 +127,10 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
);
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownSeparator = (props: AriaSeparatorProps) => {
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
return (
<AriaButton

View File

@@ -1,4 +1,5 @@
import React, { type DetailedReactHTMLElement, type HTMLAttributes, type ReactNode, cloneElement, useRef } from "react";
import type { DetailedReactHTMLElement, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, useRef } from "react";
import { filterDOMProps } from "@react-aria/utils";
interface FileTriggerProps {
@@ -41,7 +42,6 @@ export const FileTrigger = (props: FileTriggerProps) => {
const clonableElement = React.Children.only(children);
// Clone the child element and add an `onClick` handler to open the file dialog.
// eslint-disable-next-line react-hooks/refs
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
onClick: () => {
if (inputRef.current?.value) {
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
onChange={(e) => onSelect?.(e.target.files)}
capture={defaultCamera}
multiple={allowsMultiple}
// @ts-expect-error -- webkitdirectory is not in React's HTML types but is valid in modern browsers
// @ts-expect-error
webkitdirectory={acceptDirectory ? "" : undefined}
/>
</>

View File

@@ -1,5 +1,6 @@
import type { ReactNode, Ref } from "react";
import { Text as AriaText, type TextProps as AriaTextProps } from "react-aria-components";
import type { TextProps as AriaTextProps } from "react-aria-components";
import { Text as AriaText } from "react-aria-components";
import { cx } from "@/utils/cx";
interface HintTextProps extends AriaTextProps {

View File

@@ -1,6 +1,7 @@
import { type HTMLAttributes, type ReactNode } from "react";
import { HintText } from "@/components/base/input/hint-text";
import { type InputBaseProps, TextField } from "@/components/base/input/input";
import type { InputBaseProps } from "@/components/base/input/input";
import { TextField } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { cx, sortCx } from "@/utils/cx";

View File

@@ -1,6 +1,7 @@
import { useControlledState } from "@react-stately/utils";
import { HintText } from "@/components/base/input/hint-text";
import { InputBase, type InputBaseProps, TextField } from "@/components/base/input/input";
import type { InputBaseProps } from "@/components/base/input/input";
import { InputBase, TextField } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
@@ -61,7 +62,6 @@ const detectCardType = (number: string) => {
/**
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
*/
// eslint-disable-next-line react-refresh/only-export-components
export const formatCardNumber = (number: string) => {
// Remove non-numeric characters
const cleaned = number.replace(/\D/g, "");
@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
return cleaned;
};
type PaymentInputProps = Omit<InputBaseProps, "icon">;
interface PaymentInputProps extends Omit<InputBaseProps, "icon"> {}
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {

View File

@@ -1,13 +1,8 @@
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
import { faCircleExclamation, faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Group as AriaGroup,
Input as AriaInput,
type InputProps as AriaInputProps,
TextField as AriaTextField,
type TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
@@ -197,7 +192,9 @@ interface BaseProps {
}
interface TextFieldProps
extends BaseProps, AriaTextFieldProps, Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
extends BaseProps,
AriaTextFieldProps,
Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
ref?: Ref<HTMLDivElement>;
}

View File

@@ -1,7 +1,8 @@
import type { ReactNode, Ref } from "react";
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Label as AriaLabel, type LabelProps as AriaLabelProps } from "react-aria-components";
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";

View File

@@ -1,4 +1,5 @@
import { type ComponentPropsWithRef, createContext, useContext, useId } from "react";
import type { ComponentPropsWithRef } from "react";
import { createContext, useContext, useId } from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cx } from "@/utils/cx";
@@ -14,7 +15,6 @@ const PinInputContext = createContext<PinInputContextType>({
disabled: false,
});
// eslint-disable-next-line react-refresh/only-export-components
export const usePinInputContext = () => {
const context = useContext(PinInputContext);

View File

@@ -1,16 +1,9 @@
import { type FocusEventHandler, type PointerEventHandler, type RefAttributes, type RefObject, useCallback, useContext, useRef, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
import { useCallback, useContext, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
ComboBox as AriaComboBox,
type ComboBoxProps as AriaComboBoxProps,
Group as AriaGroup,
type GroupProps as AriaGroupProps,
Input as AriaInput,
ListBox as AriaListBox,
type ListBoxProps as AriaListBoxProps,
ComboBoxStateContext,
} from "react-aria-components";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Popover } from "@/components/base/select/popover";

View File

@@ -1,31 +1,14 @@
import {
type FC,
type FocusEventHandler,
type KeyboardEvent,
type PointerEventHandler,
type RefAttributes,
type RefObject,
createContext,
useCallback,
useContext,
useRef,
useState,
} from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FocusScope, useFilter, useFocusManager } from "react-aria";
import {
ComboBox as AriaComboBox,
type ComboBoxProps as AriaComboBoxProps,
Group as AriaGroup,
type GroupProps as AriaGroupProps,
Input as AriaInput,
ListBox as AriaListBox,
type ListBoxProps as AriaListBoxProps,
ComboBoxStateContext,
type Key,
} from "react-aria-components";
import { type ListData, useListData } from "react-stately";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import type { ListData } from "react-stately";
import { useListData } from "react-stately";
import { Avatar } from "@/components/base/avatar/avatar";
import type { IconComponentType } from "@/components/base/badges/badge-types";
import { HintText } from "@/components/base/input/hint-text";
@@ -37,8 +20,6 @@ import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cx } from "@/utils/cx";
import { SelectItem } from "./select-item";
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface ComboBoxValueProps extends AriaGroupProps {
size: "sm" | "md";
shortcut?: boolean;
@@ -152,7 +133,7 @@ export const MultiSelectBase = ({
// Resize observer for popover width
const onResize = useCallback(() => {
if (!placeholderRef.current) return;
const divRect = placeholderRef.current?.getBoundingClientRect();
let divRect = placeholderRef.current?.getBoundingClientRect();
setPopoverWidth(divRect.width + "px");
}, [placeholderRef, setPopoverWidth]);

View File

@@ -1,5 +1,6 @@
import type { RefAttributes } from "react";
import { Popover as AriaPopover, type PopoverProps as AriaPopoverProps } from "react-aria-components";
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
import { Popover as AriaPopover } from "react-aria-components";
import { cx } from "@/utils/cx";
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {

View File

@@ -1,11 +1,13 @@
import { isValidElement, useContext } from "react";
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ListBoxItem as AriaListBoxItem, type ListBoxItemProps as AriaListBoxItemProps, Text as AriaText } from "react-aria-components";
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
import { SelectContext, type SelectItemType } from "./select";
import type { SelectItemType } from "./select";
import { SelectContext } from "./select";
const sizes = {
sm: "p-2 pr-2.5",
@@ -81,7 +83,11 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
<FontAwesomeIcon
icon={faCheck}
aria-hidden="true"
className={cx("ml-auto text-fg-brand-primary", size === "sm" ? "size-4" : "size-5", state.isDisabled && "text-fg-disabled")}
className={cx(
"ml-auto text-fg-brand-primary",
size === "sm" ? "size-4" : "size-5",
state.isDisabled && "text-fg-disabled",
)}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { type SelectHTMLAttributes, useId } from "react";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";

View File

@@ -1,13 +1,9 @@
import { type FC, type ReactNode, type Ref, type RefAttributes, createContext, isValidElement } from "react";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import type { FC, ReactNode, Ref, RefAttributes } from "react";
import { createContext, isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button as AriaButton,
ListBox as AriaListBox,
Select as AriaSelect,
type SelectProps as AriaSelectProps,
SelectValue as AriaSelectValue,
} from "react-aria-components";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import type { SelectProps as AriaSelectProps } from "react-aria-components";
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
@@ -51,7 +47,6 @@ interface SelectValueProps {
placeholderIcon?: FC | ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
export const sizes = {
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
@@ -111,7 +106,6 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {

View File

@@ -1,8 +1,8 @@
import type { SliderProps as AriaSliderProps } from "react-aria-components";
import {
Label as AriaLabel,
Slider as AriaSlider,
SliderOutput as AriaSliderOutput,
type SliderProps as AriaSliderProps,
SliderThumb as AriaSliderThumb,
SliderTrack as AriaSliderTrack,
} from "react-aria-components";

View File

@@ -1,6 +1,6 @@
import type { RefAttributes } from "react";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -1,10 +1,7 @@
import React, { type ReactNode, type Ref } from "react";
import {
TextArea as AriaTextArea,
type TextAreaProps as AriaTextAreaProps,
TextField as AriaTextField,
type TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import type { ReactNode, Ref } from "react";
import React from "react";
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from "react-aria-components";
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
import { Switch as AriaSwitch } from "react-aria-components";
import { cx } from "@/utils/cx";
interface ToggleBaseProps {

View File

@@ -1,13 +1,10 @@
import type { ReactNode } from "react";
import {
Button as AriaButton,
type ButtonProps as AriaButtonProps,
OverlayArrow as AriaOverlayArrow,
Tooltip as AriaTooltip,
type TooltipProps as AriaTooltipProps,
TooltipTrigger as AriaTooltipTrigger,
type TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
import type {
ButtonProps as AriaButtonProps,
TooltipProps as AriaTooltipProps,
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
} from "react-aria-components";
import { Button as AriaButton, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "react-aria-components";
import { cx } from "@/utils/cx";
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
@@ -99,7 +96,7 @@ export const Tooltip = ({
);
};
type TooltipTriggerProps = AriaButtonProps;
interface TooltipTriggerProps extends AriaButtonProps {}
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
return (

View File

@@ -56,6 +56,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh.
// Cleared on disposition submit (handleDisposition below) or when call resets to idle.
useEffect(() => {
if (callUcid) {
localStorage.setItem('helix_active_ucid', callUcid);
}
return () => {
// Don't clear on unmount if disposition hasn't fired — the
// beforeunload handler in SipProvider needs it
};
}, [callUcid]);
// Detect caller disconnect: call was active and ended without agent pressing End
useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
@@ -78,9 +90,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Submit disposition to sidecar
if (callUcid) {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const disposePayload = {
ucid: callUcid,
disposition,
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
@@ -115,6 +129,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
}
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
localStorage.removeItem('helix_active_ucid');
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
handleReset();
};
@@ -306,6 +323,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
agentName={user.name}

View File

@@ -46,12 +46,12 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
try {
if (newStatus === 'ready') {
console.log('[AGENT-STATE] Changing to Ready');
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
}
// Don't setStatus — SSE will push the real state

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
@@ -8,6 +10,7 @@ import { parseDate } from '@internationalized/date';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
type ExistingAppointment = {
id: string;
@@ -32,11 +35,8 @@ type AppointmentFormProps = {
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
const clinicItems = [
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
];
// Clinics are fetched dynamically from the platform — no hardcoded list.
// If the workspace has no clinics configured, the dropdown shows empty.
const genderItems = [
{ id: 'male', label: 'Male' },
@@ -44,22 +44,8 @@ const genderItems = [
{ id: 'other', label: 'Other' },
];
const timeSlotItems = [
{ id: '09:00', label: '9:00 AM' },
{ id: '09:30', label: '9:30 AM' },
{ id: '10:00', label: '10:00 AM' },
{ id: '10:30', label: '10:30 AM' },
{ id: '11:00', label: '11:00 AM' },
{ id: '11:30', label: '11:30 AM' },
{ id: '14:00', label: '2:00 PM' },
{ id: '14:30', label: '2:30 PM' },
{ id: '15:00', label: '3:00 PM' },
{ id: '15:30', label: '3:30 PM' },
{ id: '16:00', label: '4:00 PM' },
];
const formatDeptLabel = (dept: string) =>
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
// Time slots are fetched from /api/masterdata/slots based on
// doctor + date. No hardcoded times.
export const AppointmentForm = ({
isOpen,
@@ -76,12 +62,25 @@ export const AppointmentForm = ({
// Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
// Initial name captured at form open — used to detect whether the
// agent actually changed the name before we commit any destructive
// updatePatient / updateLead.contactName mutations.
const initialLeadName = (leadName ?? '').trim();
// Form state — initialized from existing appointment in edit mode
const [patientName, setPatientName] = useState(leadName ?? '');
// The patient-name input is locked by default when there's an
// existing caller name (to prevent accidental rename-on-save), and
// unlocked only after the agent clicks the Edit button and confirms
// in the warning modal. First-time callers with no existing name
// start unlocked because there's nothing to protect.
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null);
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [date, setDate] = useState(() => {
@@ -98,6 +97,24 @@ export const AppointmentForm = ({
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [source, setSource] = useState('Inbound Call');
const [agentNotes, setAgentNotes] = useState('');
const [timeSlotItems, setTimeSlotItems] = useState<Array<{ id: string; label: string }>>([]);
// Fetch available time slots when doctor + date change
useEffect(() => {
if (!doctor || !date) {
setTimeSlotItems([]);
return;
}
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
).then(slots => {
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label })));
// Auto-select clinic from the slot's clinic
if (slots.length > 0 && !clinic) {
setClinic(slots[0].clinicId);
}
}).catch(() => setTimeSlotItems([]));
}, [doctor, date]);
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -106,24 +123,29 @@ export const AppointmentForm = ({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch doctors on mount
// Fetch doctors on mount. Doctors are hospital-wide — no single
// `clinic` field anymore. We pull the full visit-slot list via the
// Fetch clinics + doctors from the master data endpoint (Redis-cached).
// This is faster than direct GraphQL and returns pre-formatted data.
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department clinic { id name clinicName }
} } } }`,
).then(data => {
const docs = data.doctors.edges.map(e => ({
id: e.node.id,
name: e.node.fullName
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
: e.node.name,
department: e.node.department ?? '',
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
}));
setDoctors(docs);
}).catch(() => {});
apiClient.get<Array<{ id: string; name: string; phone: string; address: string }>>('/api/masterdata/clinics')
.then(clinics => {
setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' })));
}).catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
apiClient.get<Array<{ id: string; name: string; department: string; qualifications: string }>>('/api/masterdata/doctors')
.then(docs => {
setDoctors(docs.map(d => ({
id: d.id,
name: d.name,
department: d.department,
clinic: '', // clinic assignment via visit slots, not on doctor directly
})));
}).catch(() => {});
}, [isOpen]);
// Fetch booked slots when doctor + date selected
@@ -172,9 +194,18 @@ export const AppointmentForm = ({
setTimeSlot(null);
}, [doctor, date]);
// Derive department and doctor lists from fetched data
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
// Departments from master data (or fallback to deriving from doctors)
const [departmentItems, setDepartmentItems] = useState<Array<{ id: string; label: string }>>([]);
useEffect(() => {
if (!isOpen) return;
apiClient.get<string[]>('/api/masterdata/departments')
.then(depts => setDepartmentItems(depts.map(d => ({ id: d, label: d }))))
.catch(() => {
// Fallback: derive from doctor list
const derived = [...new Set(doctors.map(d => d.department).filter(Boolean))];
setDepartmentItems(derived.map(d => ({ id: d, label: d })));
});
}, [isOpen, doctors]);
const filteredDoctors = department
? doctors.filter(d => d.department === department)
@@ -245,22 +276,28 @@ export const AppointmentForm = ({
},
);
// Update patient name if we have a name and a linked patient
if (patientId && patientName.trim()) {
await apiClient.graphql(
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
updatePatient(id: $id, data: $data) { id }
}`,
{
id: patientId,
data: {
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Determine whether the agent actually renamed the patient.
// Only a non-empty, changed-from-initial name counts — empty
// strings or an unchanged name never trigger the rename
// chain, even if the field was unlocked.
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
// Update lead status + name if we have a matched lead
// DO NOT update the shared Patient entity when name changes
// during appointment creation. The Patient record is shared
// across all appointments — modifying it here would
// retroactively change the name on all past appointments.
// The patient name for THIS appointment is stored on the
// Appointment entity itself (via doctorName/department).
// Bug #527: removed updatePatient() call.
// Update lead status/lastContacted on every appointment book
// (those are genuinely about this appointment), but only
// touch lead.contactName if the agent explicitly renamed.
//
// NOTE: field name is `status`, NOT `leadStatus` — the
// staging platform schema renamed this. The old name is
// rejected by LeadUpdateInput.
if (leadId) {
await apiClient.graphql(
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
@@ -269,16 +306,26 @@ export const AppointmentForm = ({
{
id: leadId,
data: {
leadStatus: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(),
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}),
status: 'APPOINTMENT_SET',
lastContacted: new Date().toISOString(),
...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
},
},
).catch((err: unknown) => console.warn('Failed to update lead:', err));
}
// Invalidate caller cache so next lookup gets the real name
if (callerNumber) {
// If the agent actually renamed the patient, kick off the
// side-effect chain: regenerate the AI summary against the
// corrected identity AND invalidate the Redis caller
// resolution cache so the next incoming call from this
// phone picks up fresh data. Both are fire-and-forget —
// the save toast fires immediately either way.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} else if (callerNumber) {
// No rename but still invalidate the cache so status +
// lastContacted updates propagate cleanly to the next
// lookup.
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
}
}
@@ -330,12 +377,34 @@ export const AppointmentForm = ({
</span>
</div>
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
/>
{/* Patient name — locked by default for existing
callers, unlocked for new callers with no
prior name on record. The Edit button opens
a confirm modal before unlocking; see
EditPatientNameModal for the rationale. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isDisabled={!isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<Input
@@ -513,6 +582,24 @@ export const AppointmentForm = ({
</Button>
</div>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;s record. This will
update their profile across Helix Engage, including past appointments,
lead history, and AI summary. Only proceed if the current name is
actually wrong for all other cases, cancel and continue with the
appointment as-is.
</>
}
/>
</div>
);
};

View File

@@ -1,28 +1,28 @@
import type { FC } from "react";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { Call, CallDisposition } from "@/types/entities";
import type { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { formatShortDate } from '@/lib/format';
import type { Call, CallDisposition } from '@/types/entities';
interface CallLogProps {
calls: Call[];
}
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
INFO_PROVIDED: { label: "Info", color: "blue-light" },
NO_ANSWER: { label: "No Answer", color: "warning" },
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
};
const formatDuration = (seconds: number | null): string => {
if (seconds === null || seconds === 0) return "0 min";
if (seconds === null || seconds === 0) return '0 min';
const minutes = Math.round(seconds / 60);
return `${minutes} min`;
};
@@ -33,29 +33,34 @@ export const CallLog = ({ calls }: CallLogProps) => {
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">Today's Calls</span>
<Badge size="sm" color="gray">
{calls.length}
</Badge>
<Badge size="sm" color="gray">{calls.length}</Badge>
</div>
</div>
{calls.length > 0 ? (
<div className="divide-y divide-secondary">
{calls.map((call) => {
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
const config = call.disposition !== null
? dispositionConfig[call.disposition]
: null;
return (
<div key={call.id} className="flex items-center gap-3 px-5 py-3">
<span className="w-20 shrink-0 text-xs text-quaternary">{call.startedAt !== null ? formatShortDate(call.startedAt) : "—"}</span>
<div
key={call.id}
className="flex items-center gap-3 px-5 py-3"
>
<span className="w-20 shrink-0 text-xs text-quaternary">
{call.startedAt !== null ? formatShortDate(call.startedAt) : ''}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
</span>
{config !== null && (
<Badge size="sm" color={config.color}>
{config.label}
</Badge>
<Badge size="sm" color={config.color}>{config.label}</Badge>
)}
<span className="w-12 shrink-0 text-right text-xs text-quaternary">{formatDuration(call.durationSeconds)}</span>
<span className="w-12 shrink-0 text-right text-xs text-quaternary">
{formatDuration(call.durationSeconds)}
</span>
</div>
);
})}

View File

@@ -1,9 +1,9 @@
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity } from "@/types/entities";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { formatShortDate } from '@/lib/format';
import type { Lead, LeadActivity } from '@/types/entities';
interface CallPrepCardProps {
lead: Lead | null;
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => {
const dateA = a.occurredAt ?? a.createdAt ?? "";
const dateB = b.occurredAt ?? b.createdAt ?? "";
const dateA = a.occurredAt ?? a.createdAt ?? '';
const dateB = b.occurredAt ?? b.createdAt ?? '';
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 3);
@@ -29,16 +29,22 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="rounded-xl bg-brand-primary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Call Prep</span>
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
</div>
{lead.aiSummary && <p className="text-sm text-primary">{lead.aiSummary}</p>}
{lead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">{lead.aiSuggestedAction}</span>
{lead.aiSummary && (
<p className="text-sm text-primary">{lead.aiSummary}</p>
)}
{!lead.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
{lead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
{lead.aiSuggestedAction}
</span>
)}
{!lead.aiSummary && !lead.aiSuggestedAction && (
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
)}
{leadActivities.length > 0 && (
<div className="mt-3 border-t border-brand pt-3">
@@ -46,11 +52,11 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="mt-1.5 space-y-1">
{leadActivities.map((a) => (
<div key={a.id} className="flex items-start gap-2">
<Badge size="sm" color="gray" className="mt-0.5 shrink-0">
{a.activityType}
</Badge>
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
{a.occurredAt && <span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>}
{a.occurredAt && (
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
)}
</div>
))}
</div>
@@ -64,10 +70,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
<div className="rounded-xl bg-secondary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Unknown Caller</span>
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
</div>
<p className="text-sm text-secondary">
No record found for <span className="font-semibold">{callerPhone || "this number"}</span>
No record found for <span className="font-semibold">{callerPhone || 'this number'}</span>
</p>
<div className="mt-3 space-y-1.5">
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
@@ -79,11 +85,9 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
</ul>
</div>
<div className="mt-3">
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
>
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPlus} className={className} />
)}>
Create Lead
</Button>
</div>

View File

@@ -1,6 +1,6 @@
import { faPhoneArrowUpRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneArrowUpRight } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
interface CallSimulatorProps {
onSimulate: () => void;
@@ -14,14 +14,14 @@ export const CallSimulator = ({ onSimulate, isCallActive }: CallSimulatorProps)
onClick={onSimulate}
disabled={isCallActive}
className={cx(
"inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto",
'inline-flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-semibold text-white transition duration-100 ease-linear sm:w-auto',
isCallActive
? "cursor-not-allowed bg-disabled text-disabled"
: "cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]",
? 'cursor-not-allowed bg-disabled text-disabled'
: 'cursor-pointer bg-brand-solid hover:bg-brand-solid_hover [&:hover_svg]:animate-[ring-shake_0.5s_ease-in-out]',
)}
>
<FontAwesomeIcon icon={faPhoneArrowUpRight} className="size-5 shrink-0" />
{isCallActive ? "Call in progress..." : "Simulate Incoming Call"}
{isCallActive ? 'Call in progress...' : 'Simulate Incoming Call'}
</button>
);
};

View File

@@ -1,23 +1,21 @@
import { type FC, useState } from "react";
import { faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@/components/base/buttons/button";
import { notify } from "@/lib/toast";
import { useSip } from "@/providers/sip-provider";
import type { FC } from 'react';
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
);
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
import { Button } from '@/components/base/buttons/button';
import { useSip } from '@/providers/sip-provider';
import { notify } from '@/lib/toast';
interface ClickToCallButtonProps {
phoneNumber: string;
leadId?: string;
label?: string;
size?: "sm" | "md";
size?: 'sm' | 'md';
}
export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCallButtonProps) => {
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
const { isRegistered, isInCall, dialOutbound } = useSip();
const [dialing, setDialing] = useState(false);
@@ -26,7 +24,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCa
try {
await dialOutbound(phoneNumber);
} catch {
notify.error("Dial Failed", "Could not place the call");
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialing(false);
}
@@ -41,7 +39,7 @@ export const ClickToCallButton = ({ phoneNumber, label, size = "sm" }: ClickToCa
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
isLoading={dialing}
>
{label ?? "Call"}
{label ?? 'Call'}
</Button>
);
};

View File

@@ -1,11 +1,11 @@
import type { Call } from "@/types/entities";
import type { Call } from '@/types/entities';
interface DailyStatsProps {
calls: Call[];
}
const formatAvgDuration = (calls: Call[]): string => {
if (calls.length === 0) return "0.0 min";
if (calls.length === 0) return '0.0 min';
const totalSeconds = calls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
const avgMinutes = totalSeconds / calls.length / 60;
return `${avgMinutes.toFixed(1)} min`;
@@ -13,24 +13,29 @@ const formatAvgDuration = (calls: Call[]): string => {
export const DailyStats = ({ calls }: DailyStatsProps) => {
const callsHandled = calls.length;
const appointmentsBooked = calls.filter((c) => c.disposition === "APPOINTMENT_BOOKED").length;
const followUps = calls.filter((c) => c.disposition === "FOLLOW_UP_SCHEDULED").length;
const appointmentsBooked = calls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
const followUps = calls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
const avgDuration = formatAvgDuration(calls);
const stats = [
{ label: "Calls Handled", value: String(callsHandled) },
{ label: "Appointments Booked", value: String(appointmentsBooked) },
{ label: "Follow-ups", value: String(followUps) },
{ label: "Avg Duration", value: avgDuration },
{ label: 'Calls Handled', value: String(callsHandled) },
{ label: 'Appointments Booked', value: String(appointmentsBooked) },
{ label: 'Follow-ups', value: String(followUps) },
{ label: 'Avg Duration', value: avgDuration },
];
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-primary">Daily Stats</h3>
{stats.map((stat) => (
<div key={stat.label} className="rounded-xl bg-secondary p-4 text-center">
<div
key={stat.label}
className="rounded-xl bg-secondary p-4 text-center"
>
<div className="text-display-xs font-bold text-primary">{stat.value}</div>
<div className="mt-1 text-xs tracking-wider text-tertiary uppercase">{stat.label}</div>
<div className="mt-1 text-xs uppercase tracking-wider text-tertiary">
{stat.label}
</div>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { TextArea } from "@/components/base/textarea/textarea";
import type { CallDisposition } from "@/types/entities";
import { cx } from "@/utils/cx";
import { useState } from 'react';
import { TextArea } from '@/components/base/textarea/textarea';
import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx';
interface DispositionFormProps {
onSubmit: (disposition: CallDisposition, notes: string) => void;
@@ -15,46 +15,46 @@ const dispositionOptions: Array<{
defaultClass: string;
}> = [
{
value: "APPOINTMENT_BOOKED",
label: "Appointment Booked",
activeClass: "bg-success-solid text-white ring-transparent",
defaultClass: "bg-success-primary text-success-primary border-success",
value: 'APPOINTMENT_BOOKED',
label: 'Appointment Booked',
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: "FOLLOW_UP_SCHEDULED",
label: "Follow-up Needed",
activeClass: "bg-brand-solid text-white ring-transparent",
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed',
activeClass: 'bg-brand-solid text-white ring-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
},
{
value: "INFO_PROVIDED",
label: "Info Provided",
activeClass: "bg-utility-blue-light-600 text-white ring-transparent",
defaultClass: "bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200",
value: 'INFO_PROVIDED',
label: 'Info Provided',
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
},
{
value: "NO_ANSWER",
label: "No Answer",
activeClass: "bg-warning-solid text-white ring-transparent",
defaultClass: "bg-warning-primary text-warning-primary border-warning",
value: 'NO_ANSWER',
label: 'No Answer',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: "WRONG_NUMBER",
label: "Wrong Number",
activeClass: "bg-secondary-solid text-white ring-transparent",
defaultClass: "bg-secondary text-secondary border-secondary",
value: 'WRONG_NUMBER',
label: 'Wrong Number',
activeClass: 'bg-secondary-solid text-white ring-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: "CALLBACK_REQUESTED",
label: "Not Interested",
activeClass: "bg-error-solid text-white ring-transparent",
defaultClass: "bg-error-primary text-error-primary border-error",
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
];
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(defaultDisposition ?? null);
const [notes, setNotes] = useState("");
const [notes, setNotes] = useState('');
const handleSubmit = () => {
if (selected === null) return;
@@ -74,8 +74,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
type="button"
onClick={() => setSelected(option.value)}
className={cx(
"cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear",
isSelected ? cx(option.activeClass, "ring-2 ring-brand") : option.defaultClass,
'cursor-pointer rounded-xl border-2 p-3 text-xs font-semibold transition duration-100 ease-linear',
isSelected
? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass,
)}
>
{option.label}
@@ -84,7 +86,13 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
})}
</div>
<TextArea label="Notes (optional)" placeholder="Add any notes about this call..." value={notes} onChange={(value) => setNotes(value)} rows={3} />
<TextArea
label="Notes (optional)"
placeholder="Add any notes about this call..."
value={notes}
onChange={(value) => setNotes(value)}
rows={3}
/>
<div className="flex justify-end">
<button
@@ -92,10 +100,10 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
onClick={handleSubmit}
disabled={selected === null}
className={cx(
"rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear",
'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
selected !== null
? "cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover"
: "cursor-not-allowed bg-disabled text-disabled",
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
: 'cursor-not-allowed bg-disabled text-disabled',
)}
>
Save & Close Call

View File

@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
@@ -11,6 +14,11 @@ type EnquiryFormProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
callerPhone?: string | null;
// Pre-populated caller name (from caller-resolution). When set, the
// patient-name field is locked behind the Edit-confirm modal to
// prevent accidental rename-on-save. When empty or null, the field
// starts unlocked because there's no existing name to protect.
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
agentName?: string | null;
@@ -18,8 +26,14 @@ type EnquiryFormProps = {
};
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState('');
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
// Initial name captured at form open — used to detect whether the
// agent actually changed the name before committing any updatePatient /
// updateLead.contactName mutations. See also appointment-form.tsx.
const initialLeadName = (leadName ?? '').trim();
const [patientName, setPatientName] = useState(leadName ?? '');
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
const [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState('');
const [isExisting, setIsExisting] = useState(false);
@@ -72,29 +86,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
leadId = resolved.leadId;
}
// Determine whether the agent actually renamed the patient.
// Only a non-empty, changed-from-initial name counts — empty
// strings or an unchanged name never trigger the rename
// chain, even if the field was unlocked.
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
const nameParts = {
firstName: trimmedName.split(' ')[0],
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
if (leadId) {
// Update existing lead with enquiry details
// Update existing lead with enquiry details. Only touches
// contactName if the agent explicitly renamed — otherwise
// we leave the existing caller identity alone.
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: leadId,
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
...(nameChanged ? { contactName: nameParts } : {}),
},
},
);
} else {
// No phone provided — create a new lead (rare edge case)
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
@@ -104,21 +133,29 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
);
}
// Update patient name if we have a name and a linked patient
if (patientId && patientName.trim()) {
// Update linked patient's name ONLY if the agent explicitly
// renamed. Fixes the long-standing bug where typing a name
// into this form silently overwrote the existing patient
// record.
if (nameChanged && patientId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{
id: patientId,
data: {
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
fullName: nameParts,
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Invalidate caller cache so next lookup gets the real name
if (callerPhone) {
// Post-save side-effects. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation.
// Otherwise just invalidate the cache so the status update
// propagates.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
}
@@ -162,7 +199,34 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
{/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
{/* Patient name — locked by default for existing callers,
unlocked for new callers with no prior name on record.
The Edit button opens a confirm modal before unlocking;
see EditPatientConfirmModal for the rationale. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isRequired
isDisabled={!isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
@@ -206,6 +270,24 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
{isSaving ? 'Saving...' : 'Log Enquiry'}
</Button>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;s record. This will
update their profile across Helix Engage, including past appointments,
lead history, and AI summary. Only proceed if the current name is
actually wrong for all other cases, cancel and continue logging the
enquiry as-is.
</>
}
/>
</div>
);
};

View File

@@ -1,15 +1,15 @@
import { useMemo } from "react";
import { faCircleCheck, faClock, faEnvelope, faPhone, faPhoneArrowDown, faStars } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badges";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { formatPhone, formatShortDate, getInitials } from "@/lib/format";
import type { CallDisposition, Campaign, Lead, LeadActivity } from "@/types/entities";
import { DispositionForm } from "./disposition-form";
import { useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faPhoneArrowDown, faCircleCheck, faEnvelope, faClock, faStars } from '@fortawesome/pro-duotone-svg-icons';
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { DispositionForm } from './disposition-form';
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
import type { Lead, LeadActivity, CallDisposition, Campaign } from '@/types/entities';
type CallState = "idle" | "ringing" | "active" | "completed";
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
interface IncomingCallCardProps {
callState: CallState;
@@ -21,57 +21,57 @@ interface IncomingCallCardProps {
}
const activityTypeIcons: Record<string, string> = {
CALL_MADE: "phone",
CALL_RECEIVED: "phone",
WHATSAPP_SENT: "message",
WHATSAPP_RECEIVED: "message",
SMS_SENT: "message",
EMAIL_SENT: "email",
EMAIL_RECEIVED: "email",
NOTE_ADDED: "note",
ASSIGNED: "assign",
STATUS_CHANGE: "status",
APPOINTMENT_BOOKED: "calendar",
FOLLOW_UP_CREATED: "clock",
CONVERTED: "check",
MARKED_SPAM: "alert",
DUPLICATE_DETECTED: "alert",
CALL_MADE: 'phone',
CALL_RECEIVED: 'phone',
WHATSAPP_SENT: 'message',
WHATSAPP_RECEIVED: 'message',
SMS_SENT: 'message',
EMAIL_SENT: 'email',
EMAIL_RECEIVED: 'email',
NOTE_ADDED: 'note',
ASSIGNED: 'assign',
STATUS_CHANGE: 'status',
APPOINTMENT_BOOKED: 'calendar',
FOLLOW_UP_CREATED: 'clock',
CONVERTED: 'check',
MARKED_SPAM: 'alert',
DUPLICATE_DETECTED: 'alert',
};
const ActivityIcon = ({ type }: { type: string }) => {
const iconType = activityTypeIcons[type] ?? "note";
const baseClass = "size-3.5 shrink-0 text-fg-quaternary";
const iconType = activityTypeIcons[type] ?? 'note';
const baseClass = 'size-3.5 shrink-0 text-fg-quaternary';
if (iconType === "phone") return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
if (iconType === "email") return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
if (iconType === "clock") return <FontAwesomeIcon icon={faClock} className={baseClass} />;
if (iconType === "check") return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
if (iconType === 'phone') return <FontAwesomeIcon icon={faPhone} className={baseClass} />;
if (iconType === 'email') return <FontAwesomeIcon icon={faEnvelope} className={baseClass} />;
if (iconType === 'clock') return <FontAwesomeIcon icon={faClock} className={baseClass} />;
if (iconType === 'check') return <FontAwesomeIcon icon={faCircleCheck} className={baseClass} />;
return <FontAwesomeIcon icon={faClock} className={baseClass} />;
};
const dispositionLabels: Record<CallDisposition, string> = {
APPOINTMENT_BOOKED: "Appointment Booked",
FOLLOW_UP_SCHEDULED: "Follow-up Needed",
INFO_PROVIDED: "Info Provided",
NO_ANSWER: "No Answer",
WRONG_NUMBER: "Wrong Number",
CALLBACK_REQUESTED: "Not Interested",
APPOINTMENT_BOOKED: 'Appointment Booked',
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
INFO_PROVIDED: 'Info Provided',
NO_ANSWER: 'No Answer',
WRONG_NUMBER: 'Wrong Number',
CALLBACK_REQUESTED: 'Not Interested',
};
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
if (callState === "idle") {
if (callState === 'idle') {
return <IdleState />;
}
if (callState === "ringing") {
if (callState === 'ringing') {
return <RingingState lead={lead} />;
}
if (callState === "active" && lead !== null) {
if (callState === 'active' && lead !== null) {
return <ActiveState lead={lead} activities={activities} campaigns={campaigns} onDisposition={onDisposition} />;
}
if (callState === "completed") {
if (callState === 'completed') {
return <CompletedState disposition={completedDisposition ?? null} />;
}
@@ -88,7 +88,9 @@ const IdleState = () => (
);
const RingingState = ({ lead }: { lead: Lead | null }) => {
const phoneDisplay = lead?.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "+91 98765 43210";
const phoneDisplay = lead?.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: '+91 98765 43210';
return (
<div className="flex flex-col items-center justify-center rounded-2xl bg-brand-primary p-12 text-center">
@@ -98,8 +100,12 @@ const RingingState = ({ lead }: { lead: Lead | null }) => {
<FontAwesomeIcon icon={faPhoneArrowDown} className="size-12 text-fg-brand-primary" />
</div>
</div>
<span className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
<span className="text-display-xs font-bold text-primary">{phoneDisplay}</span>
<span className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">
Incoming Call
</span>
<span className="text-display-xs font-bold text-primary">
{phoneDisplay}
</span>
</div>
);
};
@@ -120,8 +126,8 @@ const ActiveState = ({
activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => {
const dateA = a.occurredAt ?? a.createdAt ?? "";
const dateB = b.occurredAt ?? b.createdAt ?? "";
const dateA = a.occurredAt ?? a.createdAt ?? '';
const dateB = b.occurredAt ?? b.createdAt ?? '';
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 3),
@@ -134,11 +140,13 @@ const ActiveState = ({
return campaign?.campaignName ?? null;
}, [campaigns, lead.campaignId]);
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
const initials = firstName && lastName ? getInitials(firstName, lastName) : "UL";
const phoneDisplay = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "No phone";
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
const initials = firstName && lastName ? getInitials(firstName, lastName) : 'UL';
const phoneDisplay = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: 'No phone';
const emailDisplay = lead.contactEmail?.[0]?.address ?? null;
return (
@@ -161,14 +169,18 @@ const ActiveState = ({
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
{lead.leadSource !== null && <SourceTag source={lead.leadSource} size="sm" />}
{lead.leadSource !== null && (
<SourceTag source={lead.leadSource} size="sm" />
)}
{campaignName !== null && (
<Badge size="sm" color="brand">
{campaignName}
</Badge>
<Badge size="sm" color="brand">{campaignName}</Badge>
)}
</div>
{lead.interestedService !== null && <p className="mt-1.5 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
{lead.interestedService !== null && (
<p className="mt-1.5 text-sm text-secondary">
Interested in: {lead.interestedService}
</p>
)}
{lead.createdAt !== null && (
<div className="mt-1 flex items-center gap-1.5 text-sm text-tertiary">
<span>Lead age:</span>
@@ -182,7 +194,9 @@ const ActiveState = ({
<div className="mt-4 rounded-xl bg-brand-primary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faStars} className="size-4 text-fg-brand-primary" />
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
AI Insight
</span>
</div>
{lead.aiSummary !== null ? (
<>
@@ -194,7 +208,9 @@ const ActiveState = ({
)}
</>
) : (
<p className="text-sm text-quaternary">No AI insights available for this lead</p>
<p className="text-sm text-quaternary">
No AI insights available for this lead
</p>
)}
</div>
@@ -205,10 +221,14 @@ const ActiveState = ({
<div className="flex flex-col gap-2">
{leadActivities.map((activity) => (
<div key={activity.id} className="flex items-start gap-2">
<ActivityIcon type={activity.activityType ?? "NOTE_ADDED"} />
<span className="flex-1 text-xs text-secondary">{activity.summary}</span>
<ActivityIcon type={activity.activityType ?? 'NOTE_ADDED'} />
<span className="flex-1 text-xs text-secondary">
{activity.summary}
</span>
<span className="shrink-0 text-xs text-quaternary">
{activity.occurredAt !== null ? formatShortDate(activity.occurredAt) : ""}
{activity.occurredAt !== null
? formatShortDate(activity.occurredAt)
: ''}
</span>
</div>
))}
@@ -220,7 +240,7 @@ const ActiveState = ({
</div>
{/* Right section: disposition form */}
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
<div className="w-full shrink-0 border-t border-secondary pt-4 lg:w-72 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0">
<DispositionForm onSubmit={onDisposition} />
</div>
</div>
@@ -229,16 +249,14 @@ const ActiveState = ({
};
const CompletedState = ({ disposition }: { disposition: CallDisposition | null }) => {
const label = disposition !== null ? dispositionLabels[disposition] : "Unknown";
const label = disposition !== null ? dispositionLabels[disposition] : 'Unknown';
return (
<div className="flex flex-col items-center justify-center rounded-2xl bg-success-primary p-8 text-center">
<FontAwesomeIcon icon={faCircleCheck} className="mb-3 size-12 text-fg-success-primary" />
<h3 className="text-lg font-bold text-success-primary">Call Logged</h3>
{disposition !== null && (
<Badge size="md" color="success" className="mt-2">
{label}
</Badge>
<Badge size="md" color="success" className="mt-2">{label}</Badge>
)}
<p className="mt-2 text-sm text-tertiary">Returning to call desk...</p>
</div>

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { faCommentDots, faEllipsisVertical, faMessageDots, faPhone } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { notify } from "@/lib/toast";
import { useSip } from "@/providers/sip-provider";
import { cx } from "@/utils/cx";
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
import { useSip } from '@/providers/sip-provider';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
type PhoneActionCellProps = {
phoneNumber: string;
@@ -27,8 +27,8 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [menuOpen]);
const handleCall = async () => {
@@ -39,7 +39,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
onDial?.();
await dialOutbound(phoneNumber);
} catch {
notify.error("Dial Failed", "Could not place the call");
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialing(false);
}
@@ -47,12 +47,12 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
const handleSms = () => {
setMenuOpen(false);
window.open(`sms:+91${phoneNumber}`, "_self");
window.open(`sms:+91${phoneNumber}`, '_self');
};
const handleWhatsApp = () => {
setMenuOpen(false);
window.open(`https://wa.me/91${phoneNumber}`, "_blank");
window.open(`https://wa.me/91${phoneNumber}`, '_blank');
};
// Long-press for mobile
@@ -77,14 +77,13 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
onClick={handleCall}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onContextMenu={(e) => {
e.preventDefault();
setMenuOpen(true);
}}
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
disabled={!canCall}
className={cx(
"flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear",
canCall ? "cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary" : "cursor-default text-tertiary",
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
canCall
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
: 'cursor-default text-tertiary',
)}
>
<FontAwesomeIcon icon={faPhone} className="size-3" />
@@ -94,18 +93,15 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
{/* Kebab menu trigger — desktop */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(!menuOpen);
}}
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 transition duration-100 ease-linear group-hover/row:opacity-100 hover:bg-primary_hover hover:text-fg-secondary"
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
</button>
{/* Context menu */}
{menuOpen && (
<div className="absolute top-full left-0 z-50 mt-1 w-40 rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary">
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
<button
type="button"
onClick={handleCall}

Some files were not shown because too many files have changed in this diff Show More