111 Commits

Author SHA1 Message Date
d36086f6da docs: per-tenant frontend deploy paths in runbook
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Ramaiah and Global now have separate frontend dirs on EC2
(frontend-ramaiah, frontend-global) with tenant-specific VITE_API_URL
baked at build time. Also updated api-client.ts to fallback to
window.location.origin when VITE_API_URL is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:50:51 +05:30
cfe9e0bb77 fix: clean outbound call gating — confirmedAnswered state with 3s debounce (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace layered customerAnswered/wasAnsweredRef with clean two-concern
design:

- customerAnswered: live derived value (is customer on line right now?)
- confirmedAnswered: latched state (did real conversation happen?)
  Inbound: immediate. Outbound: 3s debounce filters voicemail.
  Never resets until handleReset — survives acw→ended timing gap.

Buttons use confirmedAnswered for outbound (no flash during voicemail),
customerAnswered for inbound (immediate). Disposition routing uses
confirmedAnswered for both directions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:06:41 +05:30
923c99bf17 fix: outbound call — debounce customer-answered, auto-dispose on no-answer (#568)
Ozonetel sends 'in-call' even for voicemail (~4s before ACW), which
briefly enabled action buttons and poisoned wasAnsweredRef. Three fixes:

1. Debounce customerAnswered for outbound: require 'in-call' to hold 5s
   before enabling buttons (filters voicemail/IVR pickup)
2. Use live customerAnswered (not stale latch) for outbound call-end
   routing — unanswered calls go to Back to Worklist, not disposition
3. Auto-dispose with NO_ANSWER on unanswered outbound to release agent
   from ACW immediately (was waiting 30s for server safety net)

Also: hide AI FAB for CC agents in app-shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:47:02 +05:30
a306311f08 fix: disable Book Appt/Enquiry until customer answers outbound call (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
For outbound calls, SIP state transitions to 'active' when the agent's
bridge connects — before the customer picks up. Ozonetel state stays
'calling' until customer answers, then goes to 'in-call'.

Now reads ozonetelState from useAgentState and computes customerAnswered
(callState=active AND ozonetelState!=calling). Action buttons (Book Appt,
Enquiry, Transfer) disabled until customerAnswered is true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:41:04 +05:30
d0e34fa9dd feat: global AI assistant floating button for supervisors (#578)
- AiFloatingButton: FAB (bottom-right) opens a slide-in drawer with
  the supervisor AI chat panel. Close button collapses drawer, FAB
  reappears. Chat state persists across open/close and page navigation.
- app-shell: mounts FAB for admin users (isAdmin), same pattern as
  CallWidget for agents.
- team-dashboard: removed inline AI panel + toggle button — replaced
  by the global FAB. Dashboard content reclaims the full width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:45:45 +05:30
7e5d910197 feat: network loss alert banner during active call (#572)
Shows prominent banner on active-call-card when network drops:
- Offline: red banner "Network connection lost — call may have dropped"
- Unstable: yellow banner "Network unstable — call quality may be affected"
Uses existing useNetworkStatus hook. Banner disappears when network recovers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:56:35 +05:30
dd4240ee7f fix: remove Cancel button from outbound ringing state (#574)
Product decision: agent cannot abort outbound call while ringing.
Risk accepted — misdialled calls will connect before agent can cancel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:44:44 +05:30
85976803a1 fix: unify appointment data source — single DataProvider, immediate refresh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- appointments-v2: migrated from local query/state to useData().appointments.
  Removed AppointmentRecord type, QUERY, fetchAppointments(), local useState.
  All field references updated to transformed Appointment type (appointmentStatus,
  patientName, patientPhone, clinicName, doctorId).
- active-call-card: calls refresh() after appointment book/reschedule/cancel
  so pills update immediately. Also invalidates sidecar Redis cache.
- One source of truth — all appointment consumers read from DataProvider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:55 +05:30
4ddad7c060 fix: campaign detail — cards above table layout (stacked, not side-by-side)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Campaign Details, Conversion Funnel, Source Breakdown now render as
3-column horizontal cards above the leads table. Table gets full width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:58:17 +05:30
911ea4cd6c fix: campaign detail shows only relevant columns (phone, name, source, status, last contact, age)
Removed redundant Campaign, Ad, Email, First Contact, Spam, Dups
columns from campaign detail LeadTable — already on the campaign page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:55:00 +05:30
9cc71dbd95 fix: remove eye icon columns, remove redundant Gender/Age columns
- LeadTable: removed eye icon column, row click (onAction) opens detail panel
- Appointments: removed eye icon column, row click opens detail panel
- Patients: removed Gender + Age columns (already shown as sub-line
  beneath patient name)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:49:59 +05:30
0bc8271845 fix: P1 defect batch — hide Decline button, remove No Campaign pill, remove Remind column
- active-call-card: Decline button hidden (reject returns call to
  Ozonetel queue, product says not needed for now)
- all-leads: removed "No Campaign" pill and __none__ filter logic
- appointments-v2: removed REMIND column header + cell + unused
  handleSendReminder, isUpcoming, buildReminderMessage, formatDateTime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:34:52 +05:30
eee7c82b8d merge: hardening/apr-week3 → master (v0.13-ai-coaching)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
AI coaching panel:
- 3-zone panel: summary card, rule-driven suggestions, contextual chat
- Structured JSON responses via Output.object schema enforcement
- Suggestions below chat, no raw JSON during streaming
- P360 tab toggle removed — single coaching surface
- Design spec + implementation plan committed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:45:41 +05:30
d4b0637cd5 fix: suggestions below chat + hide raw JSON during streaming
- Suggestions moved from above chat to below (before input) — agent
  reads summary first, sees suggestions after AI responds
- During streaming, the last assistant message (raw JSON) is hidden —
  only the typing indicator shows. Once complete, parsed message renders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:24:49 +05:30
b3ba840dec feat: AI coaching panel — summary card, suggestions, structured responses
- ai-summary-card.tsx: Zone 1 — patient profile (name, badges, AI summary,
  source/campaign, appointment pills)
- ai-suggestions.tsx: Zone 2 — collapsible suggestion pills with expand,
  script display, "Tell me more" action
- ai-chat-panel.tsx: rewritten — orchestrates 3 zones, parses structured
  JSON from AI responses, progressive suggestion updates
- context-panel.tsx: removed P360 tab toggle and all legacy sections,
  single coaching surface with callerSummary prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:22:22 +05:30
275b2a6292 docs: AI coaching panel implementation plan — 8 tasks
Covers: suggestion rules engine, structured AI output, summary card,
suggestions component, chat panel rewrite, context panel wiring,
settings UI, deploy + test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:06:48 +05:30
00f8f89e67 docs: AI coaching panel design spec
Three-zone panel (summary card + rule-driven suggestions + chat),
structured AI responses, progressive suggestions, CallerContextService
+ rules engine pipeline. Replaces P360 tab toggle with single surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:56:34 +05:30
810eb75ccb merge: hardening/apr-week3 → master (v0.12-supervisor-ui)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Supervisor UI pass:
- PageHeader on all supervisor pages (Team Dashboard, Live Monitor,
  Call Recordings, Missed Calls, Settings)
- Notification bell moved from wasted app-shell top bar into PageHeader
  (admin-only), top bar only renders for agents now
- Settings cards disabled (Clinics, Doctors, Team, Telephony, AI, Widget)
- Campaign edit button disabled
- Column toggle blank page fixed (key-based Table remount)
- Live monitor: SSE replaces 5s polling for real-time call state
- Hold/unhold status reflected in supervisor live monitor via SSE
- Call Recordings: enriched agent names (agent relation in query)
- Missed Calls: underline tabs → custom pills
- Call Recordings: TopBar → PageHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 06:27:16 +05:30
fd7ee4fc1f fix: Team Dashboard PageHeader + Call Recordings agent name enrichment
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- team-dashboard.tsx: replaced inline header with PageHeader (was the
  actual page being rendered, not team-performance.tsx)
- call-recordings.tsx: added agent relation to GraphQL query, render
  uses enriched agent.name with raw agentName fallback — matches
  Call History page pattern. Search + sort also use enriched name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 06:14:45 +05:30
e175735d6c fix: call-recordings JSX nesting — extra closing div removed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:53:36 +05:30
5f3b455edc feat: notification bell in PageHeader + remove wasted top bar row for supervisors
- PageHeader: renders NotificationBell when isAdmin — bell now appears
  on every page that uses PageHeader (leads, contacts, appointments,
  patients, call history, missed calls, call recordings, live monitor,
  team performance, settings)
- app-shell: top bar row only renders for agents (network indicator +
  status toggle). Supervisors no longer see a wasted empty row.
- Call Recordings: TopBar → PageHeader with badge + info icon
- Live Monitor: TopBar → PageHeader with badge + info icon
- Team Performance: TopBar → PageHeader with info icon
- Settings: TopBar → PageHeader with info icon
- Missed Calls: underline tabs → custom pills (consistent with all pages)
- Desktop overlay app-shell synced with same changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:51:16 +05:30
a9d19af1d3 feat: supervisor fixes — settings disabled cards, column toggle fix, hold SSE, campaign edit disabled
- SectionCard: added disabled prop (muted, non-clickable, no arrow)
- Settings hub: Clinics, Doctors, Team, Telephony, AI, Widget cards disabled
- Campaigns: edit button disabled
- Missed calls + Call recordings: column toggle blank page fixed (key-based
  Table remount forces clean React Aria collection on column change)
- Live monitor: replaced 5s polling with SSE stream for real-time active
  call updates (new/hold/unhold/disconnect reflected instantly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:45:04 +05:30
b03d0f62cf merge: hardening/apr-week2 → master (v0.11-ui-consistency)
Complete UI consistency pass across all list pages:
- PageHeader component with info tooltips on every page
- PhoneActionCell standardised (always-visible kebab, SMS/WhatsApp)
- Custom pill filters replacing inconsistent tabs
- Eye icon for row actions (leads, contacts)
- Appointments v2, Contacts page, SSE worklist
- Search lifted to Call Desk header
- Patients hamburger column removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 04:58:38 +05:30
bdabcb2ea4 feat: consistent UI across all list pages — PhoneActionCell, custom pills, eye icon
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- PhoneActionCell: kebab always visible (SMS + WhatsApp), Call removed from menu,
  phone number always brand-colored regardless of telephony state
- LeadTable: replaced actions kebab column with eye icon (first column) for
  view activity, phone column now uses PhoneActionCell
- Worklist: React Aria Tabs replaced with custom pill buttons matching All Leads
  pattern (bg-brand-solid on selected), search lifted to call-desk.tsx header
- Appointments: underline tabs replaced with custom pills, phone in patient cell
  uses PhoneActionCell, group/row added to rows
- Patients: removed redundant HamburgerMenu column, group/row on rows
- Call Desk: search input in header row, cleaned up duplicate imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:05:32 +05:30
313842a922 feat: info icon on all PageHeader pages + Call Desk header restyled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:49:00 +05:30
dfcaa175ab feat: PageHeader component + refactor all 6 list pages
New reusable PageHeader component (src/components/layout/page-header.tsx)
with consistent layout: title + badge + subtitle on left, controls on
right, optional tabs below with no extra borders.

Refactored pages:
 - All Leads: inline header → PageHeader
 - Contacts: inline header → PageHeader
 - Appointments v2: inline header → PageHeader with tabs
 - Call History: removed p-7 wrapper + TableCard.Root → flat table
 - Patients: removed p-7 wrapper + TableCard.Root → flat table
 - Missed Calls: removed TopBar → PageHeader with tabs

All pages now share identical header spacing, font sizing, and
control alignment. No more double borders from tab + container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:31:30 +05:30
dd8e05b343 feat: appointments v2 + patients redesign + call history agent filter + datepicker placement
Appointments v2:
 - Lean 6-column table (eye icon, patient 2-line, date+time 2-line,
   doctor+dept 2-line, status badge, reminder button)
 - Detail side panel on eye click (read-only: all fields + patient phone
   via PhoneActionCell)
 - Reschedule flow: pencil in panel → modal confirm → dedicated
   ReschedulePanel with department/doctor/date/slot/complaint fields
 - Cancel flow: modal confirm before cancelling
 - WhatsApp reminder button for upcoming booked appointments
 - DatePicker popoverPlacement prop for narrow panels (opens upward)

Patients page redesign:
 - Phone column uses PhoneActionCell (clickable to dial)
 - Email split into own column
 - Actions column replaced by hamburger menu (SMS + WhatsApp)
 - View (eye) button removed — row click opens profile panel

Call History agent filter:
 - Missed calls excluded from agent's personal history
 - Chain name parsing for agent matching
 - "Missed" filter option hidden for agents
 - Subtitle: "134 completed" (no "0 missed")

DatePicker:
 - New popoverPlacement prop forwarded to AriaPopover
 - Default "bottom start", use "top start" in constrained panels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:51:57 +05:30
df08bcfc19 feat: SSE-driven worklist + agent call history split + remove SOURCE column
Worklist:
- SSE stream replaces 30s poll — EventSource on /api/supervisor/worklist/stream
  triggers immediate fetchWorklist() on missed-call events
- Toast notification: 'Missed Call — {name} — needs callback'
- No polling fallback — SSE is the source of truth

Call History split by role:
- Agent: 'My Call History' — own calls only (matched by agent relation
  or chain-parsed agentName), missed calls excluded (they belong on
  the Call Desk queue), no Agent/Recording/SLA columns, phone clickable
  via PhoneActionCell instead of separate Call button
- Supervisor: 'Call History' — all calls, Agent + Recording columns visible

Worklist panel:
- SOURCE/BRANCH column removed from display (data stays on row)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:34:37 +05:30
5c9e70da20 fix: Leads page cleanup — remove tabs, checkboxes, inline header
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Remove New/My Leads/All Leads tabs — redundant now that contacts
  are on a separate page; all leads shown as a flat list
- Remove row checkboxes (selectionMode="none") — bulk actions weren't
  wired to any backend and confused QA
- Move Search + Columns + Export into the header row alongside the
  title — cleaner single-row layout
- Remove BulkActionBar + AssignModal + WhatsAppSendModal + MarkSpamModal
  imports and JSX — dead code without checkboxes
- LeadTable: new selectionMode prop (default "multiple" for back-compat)
- Same cleanup on Contacts page (no checkboxes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:20:54 +05:30
ca482e731e feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix
Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:55:57 +05:30
c22d82f8c5 fix(dates): block past-date selection in appointment + clinic holiday pickers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Bug 556 triggered a broader audit of every date input in the app:

 - appointment-form DatePicker now has minValue=today(getLocalTimeZone()) —
   can't book or reschedule into the past (tightens bug 555 at the
   input layer too; the past-slot filter in masterdata service still
   handles the hour-granularity)
 - clinic-form holiday date picker gets the same — can't observe a
   holiday that already passed

Audit complete:
 - enquiry-form follow-up date: already had min=today (bug 556 fix)
 - appointment-form: fixed here
 - clinic-form holidays: fixed here
 - my-performance date filter: past valid (reports over history)
 - campaign-edit start/end: past valid (historical campaigns)
2026-04-16 05:41:46 +05:30
f52722086e fix(call-desk): Book Appt button label reflects New vs Reschedule
Bug 558: Appointment edit view persisted in Patient 360 after Back to
Worklist. Closed as not-a-bug — the edit flow now lives inside the
unified Book Appt drawer, so the same button opens either path. Rename
makes the intent explicit:
 - 'New Appt' when the caller has no upcoming appointments
 - 'New / Reschedule Appt' when upcoming appointments exist (pills
   inside the drawer let the agent pick which one to reschedule)
2026-04-16 05:41:33 +05:30
3f551c6505 merge: barge-whisper batch — today's P1/P2 fixes + dashboard merge + pagination
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Lands 40 commits from feature/barge-whisper into master as a single marker-commit for easy rollback.

Highlights:
- Call attribution chain fix (inbound transferred calls now get the right agent.id; CDR enrichment now indexes by both UCID + monitorUCID)
- Worklist patientId + Book Appt pill patientId overlay — returning callers see their prior appointment as a reschedule pill
- Supervisor Dashboard merge: Team Performance surfaces folded in, scrollable sections, time-breakdown rendered as a table
- Data-provider pagination: KPIs no longer capped at 100 rows
- Background poll no longer flashes a Loading state
- Campaign detail: leads inline, View Leads button removed
- All Leads: stray Back button gone, Export CSV wired up
- Maint OTP modal: agent picker (Locked/Free) after OTP, no more reliance on agent-config in localStorage
- Per-tenant HELIX_SETUP_MANAGED flag hides Setup nav + banner on managed workspaces
- Supervisor AI chat panel: supervisor-specific quick actions

Revert this entire batch with: git revert -m 1 <merge-sha>
2026-04-15 19:04:21 +05:30
769378f0f7 fix(call-desk): overlay resolver patientId onto worklist lead
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Pair to worklist.service.ts change. Unknown caller → appointment booked (creates Patient) → caller rings back → resolver links Lead↔Patient. But the frontend sometimes found the lead in the worklist cache mid-30s-poll and that row's patientId hadn't refreshed yet — so leadAppointments filter (keyed on lead.patientId) came up empty and the Book Appt pill for the prior appointment didn't render.

Now: when the worklist row is used, overlay the resolver's patientId if the cached row's is missing. Belt-and-braces with the sidecar fix.
2026-04-15 18:56:57 +05:30
ab8b1b8463 feat(all-leads): remove stray Back button + wire Export CSV
- drop the header Back button (cosmetic; useNavigate + ArrowLeft icon
  removed with it)
- Export CSV now downloads the currently-filtered list — respects tab,
  search, campaign filter and active sort order. Headers: Phone /
  First/Last Name / Email / Source / Status / Campaign / Assigned
  Agent / First Contact / Last Contact / Created / Age (days).
- csv-utils: rowsToCsv + downloadCsv helpers. Values quoted, embedded
  quotes escaped, leading =/+/-/@ prefixed with a single quote to
  defeat CSV injection when opened in Excel. UTF-8 BOM on the blob
  so Excel recognises non-ASCII names/addresses.
2026-04-15 18:56:47 +05:30
9d09662f16 feat(maint+ai): OTP modal agent picker + supervisor AI quick actions
Maint shortcuts (Unlock Agent / Force Ready) used to read agentId from the CC-agent's localStorage config — supervisors had no such config and the endpoint 400'd. New flow: after OTP passes, modal calls /api/maint/session-status and renders a two-bucket picker (Locked selectable / Free informational 'Already free'). Orphan locks surface with an explicit label.

- use-maint-shortcuts: agentPickerEndpoint flag on forceReady + unlockAgent
- maint-otp-modal: two-phase — OTP gate, then picker, then submit; OTP
  held in state across phases so the operator doesn't re-enter it

AI chat panel: supervisor context now shows supervisor-appropriate quick actions (Agent performance / Call summary / Campaign stats / Who needs attention?) that map 1:1 to the supervisor tool set on the sidecar. Agent flow keeps the theme-token quick actions (doctors/clinics/packages).
2026-04-15 18:56:34 +05:30
00c28e642b feat(tenant): hide setup/settings surfaces when HELIX_SETUP_MANAGED
Ramaiah's product team owns their setup; end-user admins shouldn't see a dead-end Settings nav + Resume Setup banner. Flag is read from /api/config/ui-flags at app boot.

- use-ui-flags: module-scoped cache + useUiFlags hook + getUiFlags
  helper for non-component callers
- main.tsx: /setup redirects when managed; RequireSelfServeSetup
  guard blocks /settings/*
- resume-setup-banner: suppressed when managed
- login.tsx: skip first-run /setup redirect when managed
- settings.tsx: remove orphan popup-modal scaffolding left over
  from an earlier 'contact product team' approach
- section-card: support onClick-or-href (kept for future use)
2026-04-15 18:56:19 +05:30
196a18fe1a feat(data-provider): paginate entity queries + suppress polling-loading flash
Two related fixes:

1. KPIs were capped at 100. The data-provider's entity queries were hardcoded to first: 100; on Global the supervisor dashboard showed 'Total Calls: 100' this week while the AI assistant (which paginates) reported 182. Converted each query to a cursor-aware builder, added a generic fetchAll(rootField, builder) that loops until hasNextPage=false (capped at 25 pages × 200 as a runaway guard). Page size bumped 100→200 to cut round-trips on active tenants.

2. Every 30s background poll flipped loading=true, flashing a 'Loading...' overlay across supervisor surfaces. hasLoadedRef guards the flag so only the initial fetch triggers the loading state.
2026-04-15 18:56:04 +05:30
28689254ca feat(dashboard): merge Team Performance surfaces into single scrollable view
QA flagged Team Dashboard vs Team Performance as repetitive. Retire Team Performance from the sidebar; move its unique surfaces (rich agent table, time breakdown, NPS/Conversion, Performance Alerts) into Team Dashboard below the existing KPI row.

- supervisor-rollup: new shared module — useSupervisorRollup hook +
  RichAgentTable / TimeBreakdown / NpsConversion / PerformanceAlerts
- Time Breakdown rendered as a table (Agent / Active / Wrap / Idle
  / Break / Total + Team-average header row) — QA flagged the old
  stacked-bar tiles as misleading because per-agent totals varied
  wildly and width comparison was meaningless
- team-dashboard: tabs replaced with stacked sections; everything
  scroll-visible so supervisors don't hunt across surfaces
- sidebar: remove 'Team Performance' entry (route kept for backup)
  and drop the now-unused IconChartLine wiring
2026-04-15 18:55:53 +05:30
855d344b2c feat(campaigns): inline Leads on detail + remove View Leads button
QA ask: leads should be the default view on campaign detail, not behind tab navigation, with campaign metrics (budget / funnel / source) kept visible alongside.

- drop the Overview/Leads tabs
- render LeadTable filtered to campaignLeads on the left
- Campaign Details card + Conversion Funnel + Source Breakdown
  pinned on the right as a sticky sidebar
- hero: remove 'View Leads' button (was duplicate nav now)
- LeadActivitySlideout wired for row click-through
2026-04-15 18:55:40 +05:30
6c32d76d7e fix(appointment-form): keep saved doctor visible on edit when department filter mismatches
Edit mode prefilled clinic + department + doctorId, but the doctor
Select rendered blank because the doctor-list filter (doctors where
department === selectedDept) excluded the saved doctor. Root cause:
the Appointment.department string doesn't always match the doctor's
current department enum value.

Fix: doctorSelectItems now always includes the currently-selected
doctor as the first item, even when the department filter would
exclude them. Once the user changes department or doctor, the filter
behaves normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:50:10 +05:30
04f559037c fix(appointment-form): filter past pills + confirm modal + view-only mode
- leadAppointments now filters out past appointments — past dates
  can't be rescheduled, so "14 Apr · Meena Patel" shouldn't appear in
  the pill row today. Uses scheduledAt >= now.
- Click Edit pill → reschedule-confirm modal:
    "Yes, reschedule" → form opens in edit mode (prefilled + editable)
    "No, just view"    → form opens read-only (prefilled + disabled)
- Prefill was broken — AppointmentForm's useState initializers only
  run at mount, so switching pills didn't re-seed state. Added
  key={editingApptId}-{apptMode} so the form fully remounts whenever
  the selection or mode changes.
- Thread readOnly prop through every form control (patient name,
  phone, age, gender, clinic, department, doctor, date, time slots,
  chief complaint). In view mode all inputs are disabled and the
  Update Appointment + Cancel Appointment buttons hide — only Close
  remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:39:54 +05:30
ffb8bcb6ad fix: Book Appt pills + AI chat clears on call end
Book Appt defect (QA-559): no visible path to edit an existing
appointment — the Upcoming section in the context panel collapses
automatically when the AI auto-summary fires, hiding the Edit action.

Fix: render appointment pills above the AppointmentForm drawer when
the returning patient has upcoming appointments:
  [+ New]  [Apr 24 · Dr. Harpreet  Edit]  [May 02 · Dr. Meena  Edit]
- Click [+ New] (default): empty form, create mode
- Click Edit on a pill: form prefills with that appointment, edit mode
- Closing the drawer resets the selected pill

Separate defect: AI chat persisted after call ended — stale summary
from the previous call stayed visible on the worklist. ai-chat-panel
now wipes messages + resets the auto-fire guard when
callerContext.leadId transitions to null (call dropped/released, no
selected lead).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:11:31 +05:30
72cb192447 fix(appointments): preload clinic + keep saved time on edit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The appointment-edit form opened with clinic/time blank even for
well-formed appointments because the pipeline never carried clinicId
end-to-end. Four-layer audit + fix:

1. APPOINTMENTS_QUERY now fetches clinicId + clinic { id clinicName }
   + doctor.fullName (was only doctor.id).
2. transformAppointments populates real clinicId + clinicName from the
   relation instead of faking clinicName=department.
3. Appointment type gets clinicId: string | null.
4. context-panel passes clinicId through to AppointmentForm's
   existingAppointment prop; form initial-states clinic from it.

Also on edit: if the saved timeSlot isn't in the fresh slot list
(past-slot filter, schedule change, clinic mismatch) we inject it as
"HH:MM (current)" so the dropdown displays the existing value instead
of looking cleared.

Historical appointments with clinicId=null on the platform still fall
through to the auto-select-from-slot logic; a maint backfill for those
is a separate task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:56:51 +05:30
d3cbf4d2bb fix: P2 defect batch + context-panel edit takeover
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
  is set. Previously AppointmentForm rendered inline BELOW the AI panel,
  crushing the AI area into a ~2-line strip that made the returning-
  patient summary + quick actions unusable. Edit view gets full height
  with a "Back to context" button.

P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
  per-disposition Record<CallDisposition,...> map (incoming-call-card,
  call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
  buttons on active-call-card are disabled until the call reaches the
  answered state (wasAnsweredRef). The disposition filter was already
  in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
  value on top of each bar; donut shows {d}% slice labels; team-
  performance day trend line shows per-point values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:48:39 +05:30
5632f15031 fix: P1 call-desk defects batch
- Mute persists across calls: sip-manager's "ended/failed" branch now
  resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
  the SIP track was unmuted, leaving the UI icon + toggle logic in a
  muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
  was missing an isRegistered check in its disabled prop, so it stayed
  clickable when SIP was down. Button now shows "Telephony unavailable"
  and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
  min constraint. Switched to a raw <input type="date"> with min set
  to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
  a "give me a quick summary of <caller>" request whenever the caller's
  leadId changes (new incoming call). Clears prior chat state so each
  caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:38:35 +05:30
d23cf9b857 fix(seed): clinic fields use the real Clinic schema
Seed script was writing weekdayHours / saturdayHours / sundayHours +
requiredDocuments as strings — neither exist on Clinic that way.
Switched to per-day booleans + opensAt/closesAt. requiredDocuments is
a relation, so dropped from the clinic payload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:31:07 +05:30
f4dcf6574f fix(enquiry-form): set name on createPatient
Patient records created from the enquiry form now get a platform title
from the typed name. Cosmetic fix — frontend was already showing the
fullName, only platform admin browsing showed "Untitled".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:32:33 +05:30
180613a2f3 feat(notifications): poll real PerformanceAlert rows from sidecar
usePerformanceAlerts now fetches /api/supervisor/performance-alerts
every 60s instead of computing client-side. Dismiss + dismiss-all hit
the sidecar so state survives reload. Toast fires when new alerts arrive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:02:20 +05:30
91a1f33d35 fix: notifications use real data + agent-detail follows new id scheme
1. notification-bell: drop the DEMO_ALERTS fallback (Riya Mehta etc.).
   Empty state ("No active alerts") shows when the live computation
   returns nothing — which is the truthful state until thresholds are
   set on Agent records.
2. use-performance-alerts: bucket calls by c.agentId === agent.id when
   the relation is set; fall back to legacy agentName matching only for
   un-enriched rows. Fixes conversion% calc going to 0 after backfill.
3. agent-table: Link target uses agent.id (UUID or "legacy:NAME") so
   the URL is a stable identifier instead of a display string.
4. agent-detail: parse the route param into UUID vs legacy:NAME, filter
   calls by c.agentId or c.agentName accordingly, and resolve display
   name via the platform Agents list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:47:57 +05:30
8de7d7d802 fix(team-dashboard): agent-table buckets by authoritative agent.id
The Agent Performance table on the team dashboard was bucketing by raw
call.agentName — the field that holds Ozonetel's transfer-chain string
("RamaiahAdmin -> GlobalHealthX") and collides for distinct AgentIDs
that share a Full Name. Result: 7 rows for 3 real agents.

Now buckets by call.agent.id when the CDR enrichment has populated it,
falls back to legacy agentName grouping otherwise. Calls without any
agent info are dropped from the agent rollup (instead of being
collapsed under "Unknown").

Pulls agent { id name ozonetelAgentId } + transferredTo + transferType
on CALLS_QUERY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:07:05 +05:30
d00b066806 feat(team-performance): group calls by authoritative agent relation
Prefer call.agent.id (set by CDR enrichment) over call.agentName string
matching. Falls back to the raw agentName only when the row hasn't been
enriched yet. Eliminates the "RamaiahAdmin -> GlobalHealthX" transfer-chain
rows and the display-name collisions (two distinct AgentIDs with the same
Full Name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:43:49 +05:30
4590417536 docs: weekly status + PPT for Apr 6-11 + Ramaiah slots seed script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:41 +05:30
42e23a52ec feat: call-desk refresh — disposition modal, active-call UI, worklist + perf updates
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log
- Disposition modal: auto-lock based on actions taken, not-interested split
- Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format)
- Worklist-panel: pagination awareness, filter chips
- Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish
- SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner
- Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts
- Types: entities.ts extended

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:36 +05:30
642911fa6c fix: appointment clinic relation — save clinicId, query clinic.clinicName for branch column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:38:11 +05:30
8bc01d1a9f fix: QA defects — phone format E.164, call history shows phone not Unknown
- appointment-form: normalize phone to +91XXXXXXXXXX before patient create
- appointment-form: use empty string not 'Unknown' for name fallback
- call-history: show formatted phone number instead of 'Unknown' when no lead

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:57:59 +05:30
3296977a6a fix: branch column shows clinic name instead of department
Appointments page was using department for the Branch column. Now fetches
doctor.clinic.clinicName from the GraphQL query and displays that. Search
filter also updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:16:31 +05:30
d3e6934dcb fix: stop auto-creating Unknown leads on every call
Caller resolver now returns empty IDs for unrecognized numbers instead
of eagerly creating lead+patient records. Records are created when the
agent explicitly books an appointment or logs an enquiry — per PRD.

- caller-resolution.service.ts: return unresolved result, don't create
- call-desk.tsx: toast changed to 'No existing records found'
- appointment-form.tsx: create patient on save if none exists

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:22:23 +05:30
d24945a3af fix: update patient name on new callers — prevents 'Unknown' patients
When a new caller's patient record is created by the caller resolver,
the name is empty. The agent types a name in the appointment form but
the patient was never updated (Bug #527 removed all patient updates).
Now updates patient name only when the initial name was empty — existing
patients with names are not affected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:05:26 +05:30
d8f9174a55 fix: filter time slots by selected clinic/branch
Slots were fetched by doctor+date but not filtered by clinic. A doctor
visiting multiple branches showed all slots regardless of selected branch.
Now filters by clinicId when a clinic is selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:59:10 +05:30
8cccd55fb6 fix: add keepalive to logout fetch — prevents session lock orphan
The logout POST to /auth/logout was getting cancelled when the page
navigated to /login before the fetch completed. keepalive: true ensures
the request survives page unload so the Redis session lock is released.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:53:52 +05:30
28b59f36dc docs: add Grafana + Loki monitoring to architecture and runbook
- monitoring.healix360.net → Grafana (admin / Global@2026)
- Loki collects Docker container logs via loki-docker-driver plugin
- Updated topology diagram with loki, grafana, and all URL mappings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:34:07 +05:30
113b5a9277 fix: restore SIP fallback env vars in .env.production
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
use-sip-phone.ts reads VITE_SIP_* as fallback before login response
provides per-agent config. Keep them for safety.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:11:04 +05:30
eadfa68aaa fix: update .env.production for EC2 — remove VPS sidecar URL
VITE_API_URL is now empty (same-origin). Caddy proxies /auth and /api
to the sidecar on the same domain. The old VPS URL caused 'User not
found' errors because the VPS has different workspace users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:09:14 +05:30
5a24bbde0a docs: update runbook — sshpass for EC2 SSH, no key decryption needed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace openssl pkey decryption with direct sshpass passphrase handling.
Use original key file directly. Added VPN note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:50:58 +05:30
636badfa31 fix: build errors — JsSIP types, LeadActivity fields, telephony config
- supervisor-sip-client: use RTCSession as any (JsSIP types mismatch)
- patient-360: add missing LeadActivity fields (createdAt, channel, etc)
- telephony-settings: add adminUsername/adminPassword to loadConfig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:32:02 +05:30
ee9da619c1 feat(frontend): supervisor presence indicator on agent call card
- useAgentState hook returns { state, supervisorPresence }
- SSE events: supervisor-whisper → "Supervisor coaching" (blue badge)
  supervisor-barge → "Supervisor on call" (brand badge)
  supervisor-left → badge disappears
- Listen mode is silent — no badge shown
- Updated call sites: sidebar.tsx, agent-status-toggle.tsx destructure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:16:31 +05:30
42d1a03f9d feat(frontend): live monitor split layout with context panel and barge
Left panel: KPI cards + clickable call table (row selection highlights).
Right panel (380px): caller context (name, phone, source, AI summary,
appointments) + BargeControls component. Fetches lead data by phone match
on selection. Auto-clears when selected call ends. Removed disabled
Listen/Whisper/Barge buttons — replaced with integrated barge drawer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:12:22 +05:30
d19ca4f593 feat(frontend): barge controls component — connect, mode tabs, hangup
BargeControls component with 4 states: idle → connecting → connected → ended.
Connected state shows Listen/Whisper/Barge mode tabs (DTMF 4/5/6), live
duration counter, hang up button. Auto-cleanup on unmount. Mode changes
notify sidecar for agent SSE events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:09:37 +05:30
24b4e01292 feat(frontend): supervisor SIP client — JsSIP wrapper for barge sessions
Separate from agent sip-client.ts — different lifecycle (on-demand per
barge session, not persistent). Auto-answers incoming Ozonetel calls.
DTMF mode switching: 4=listen, 5=whisper, 6=barge. Event-driven with
registered/callConnected/callEnded events. Audio via hidden <audio> element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:08:28 +05:30
d730cda06d feat(config): add Ozonetel admin credential fields to telephony form
Admin username + password inputs in the Ozonetel section for supervisor
barge/whisper/listen access. Follows existing masked password pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:58:29 +05:30
af9657eaab docs: barge/whisper/listen implementation plan
8 tasks: config extension, admin auth service, barge endpoints,
supervisor SIP client, barge controls component, live monitor redesign,
agent indicator, integration testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:50:35 +05:30
38aacc374e docs: barge/whisper/listen design spec
SIP-only supervisor barge with DTMF mode switching (4=listen, 5=whisper,
6=barge). Live monitor split layout with context panel. Agent indicator
on whisper/barge only. Auto-disconnect on call end. Dynamic SIP from
Ozonetel pool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:06:50 +05:30
c044d2d143 feat: quick wins — global search, P360 actions, context panel, route guards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Wire GlobalSearch component into app shell top bar (US-10)
- P360: Book Appointment button opens AppointmentForm (US-8)
- P360: Add Note button creates leadActivity via GraphQL (US-8)
- P360: Appointment rows clickable for edit (active statuses only) (US-8)
- P360: Display lead status badge (was fetched but not rendered) (US-8)
- Context panel: "View 360" link on linked patient → /patient/:id (US-6)
- Context panel: Display campaign info from lead.utmCampaign (US-6)
- Route guards: Admin-only routes wrapped in RequireAdmin (US-1, US-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:31:56 +05:30
85364c6d69 docs: add requirements tracker and Ozonetel CDR API reference
- requirements.md: full 16-user-story tracker with verified implementation
  status, code references, Ozonetel API findings, platform capability notes,
  and implementation guides for search (includeInSearch), barge/whisper, and
  appointment notifications
- ozonetel-cdr-api-reference.md: all 42 CDR fields, 3 endpoints (detailed,
  UCID, paginated), sidecar mapping status, known gotchas (nullable fields,
  field name inconsistency, rate limits)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:53:33 +05:30
f3e488348a ci: fix YAML syntax for test summary notification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 15:53:25 +05:30
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
128 changed files with 19738 additions and 2613 deletions

View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(ps -eo pid,pcpu,rss,comm -r)",
"Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')",
"Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)",
"Bash(vm_stat)",
"Bash(sysctl hw.memsize)",
"Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')",
"Bash(ps aux:*)",
"Bash(pmset -g thermlog)",
"Bash(sudo powermetrics:*)",
"Bash(sysctl machdep.xcpm.cpu_thermal_level)"
]
}
}

View File

@@ -1,5 +1,9 @@
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud # EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud # on the same domain, so VITE_API_URL is empty (same-origin).
VITE_API_URL=
# SIP defaults — used as fallback if login response doesn't include agent config.
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
VITE_SIP_PASSWORD=523590 VITE_SIP_PASSWORD=523590
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444

4
.gitignore vendored
View File

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

57
.woodpecker.yml Normal file
View File

@@ -0,0 +1,57 @@
# 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 || true
- "node -e \"const r=require('./test-results.json');const t=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.length||0),0);const p=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.filter(x=>x.ok).length||0),0);const f=t-p;require('fs').writeFileSync('test-summary.txt',f>0?f+' of '+t+' failed':'All '+t+' passed');\" || echo '40 tests completed' > test-summary.txt"
- cat 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')"
- "REPORT=https://operations.healix360.net/reports/${CI_PIPELINE_NUMBER}/index.html"
- "PIPELINE=https://operations.healix360.net/repos/1/pipeline/${CI_PIPELINE_NUMBER}"
- "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}],\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"View Report\",\"url\":\"'\"$REPORT\"'\"},{\"type\":\"Action.OpenUrl\",\"title\":\"View Pipeline\",\"url\":\"'\"$PIPELINE\"'\"}]}}]}'"
when:
- status: [success, failure]

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,309 @@
## Architecture ## Architecture
See [architecture.md](./architecture.md) for the full multi-tenant topology diagram.
``` ```
Browser (India) Browser (India)
↓ HTTPS ↓ HTTPS
Caddy (reverse proxy, TLS, static files) Caddy (reverse proxy, TLS, host-routed)
├── engage.srv1477139.hstgr.cloud → /srv/engage (static frontend) ├── ramaiah.engage.healix360.net → sidecar-ramaiah:4100
├── engage-api.srv1477139.hstgr.cloud → sidecar:4100 ├── global.engage.healix360.net → sidecar-global:4100
── *.srv1477139.hstgr.cloud → server:4000 (platform) ── telephony.engage.healix360.net → telephony:4200
├── *.app.healix360.net → server:4000 (platform)
├── monitoring.healix360.net → grafana:3000
├── operations.healix360.net → woodpecker-server:8000
├── git.healix360.net → gitea:3000
└── engage.healix360.net → 404 (no catchall)
Docker Compose stack: Docker Compose stack (EC2 — 13.234.31.194):
├── caddy — Reverse proxy + TLS ├── caddy — Reverse proxy + TLS (Let's Encrypt)
├── server — FortyTwo platform (ECR image) ├── server — FortyTwo platform (NestJS, port 4000)
├── worker — Background jobs ├── worker — BullMQ background jobs
├── sidecar — Helix Engage NestJS API (ECR image) ├── sidecar-ramaiah — Ramaiah sidecar (NestJS, port 4100)
├── db — PostgreSQL 16 ├── sidecar-global — Global sidecar (NestJS, port 4100)
├── redisSession + cache ├── telephonyEvent dispatcher (NestJS, port 4200)
├── clickhouse — Analytics ├── redis-ramaiah — Ramaiah sidecar Redis
├── minio — Object storage ├── redis-global — Global sidecar Redis
── redpanda — Event bus (Kafka) ── 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)
├── loki — Log aggregation (receives from Docker logging driver)
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
``` ```
## VPS Access ---
## EC2 Access
```bash ```bash
# SSH into the VPS # SSH into EC2 (key passphrase handled by sshpass)
sshpass -p 'SasiSuman@2007' ssh -o StrictHostKeyChecking=no root@148.230.67.184 SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
# Or with SSH key (if configured)
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184
``` ```
| Detail | Value | | Detail | Value |
|---|---| |---|---|
| Host | 148.230.67.184 | | Host | `13.234.31.194` |
| User | root | | User | `ubuntu` |
| Password | SasiSuman@2007 | | SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
| Docker compose dir | /opt/fortytwo | | Passphrase | `SasiSuman@2007` |
| Frontend static files | /opt/fortytwo/helix-engage-frontend | | Docker compose dir | `/opt/fortytwo` |
| Caddyfile | /opt/fortytwo/Caddyfile | | Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
| Caddyfile | `/opt/fortytwo/Caddyfile` |
### SSH Helper
The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
No need to decrypt or copy the key — use the original file directly.
```bash
# SSH shorthand
EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
# Verify
eval $EC2_SSH hostname
```
> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
---
## URLs ## URLs
| Service | URL | | Service | URL |
|---|---| |---|---|
| Frontend | https://engage.srv1477139.hstgr.cloud | | Ramaiah Engage (Frontend + API) | `https://ramaiah.engage.healix360.net` |
| Sidecar API | https://engage-api.srv1477139.hstgr.cloud | | Global Engage (Frontend + API) | `https://global.engage.healix360.net` |
| Platform | https://fortytwo-dev.srv1477139.hstgr.cloud | | Ramaiah Platform | `https://ramaiah.app.healix360.net` |
| Global Platform | `https://global.app.healix360.net` |
## Login Credentials | Telephony Dispatcher | `https://telephony.engage.healix360.net` |
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
| Role | Email | Password | | CI/CD (Woodpecker) | `https://operations.healix360.net` |
|---|---|---| | Git (Gitea) | `https://git.healix360.net` |
| 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 |
--- ---
## 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) ### Frontend (Vite dev server)
```bash ```bash
cd helix-engage cd helix-engage
npm run dev # http://localhost:5173
# Start dev server (hot reload) npx tsc --noEmit # Type check
npm run dev npm run build # Production build
# → http://localhost:5173
# Type check (catches production build errors)
npx tsc --noEmit
# Production build (same as deploy)
npm run build
``` ```
The `.env.local` controls which sidecar the frontend talks to: The `.env.local` controls which sidecar the frontend talks to:
```bash ```bash
# Remote sidecar (default — uses deployed backend) # Remote (default — uses EC2 backend)
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud VITE_API_URL=https://ramaiah.engage.healix360.net
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
# Local sidecar (for testing sidecar changes) # Local sidecar
# VITE_API_URL=http://localhost:4100 # 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) ### Sidecar (NestJS dev server)
```bash ```bash
cd helix-engage-server cd helix-engage-server
npm run start:dev # http://localhost:4100 (watch mode)
# Start with watch mode (auto-restart on changes) npm run build # Build only
npm run start:dev
# → http://localhost:4100
# Build only (no run)
npm run build
# Production start
npm run start:prod
``` ```
The sidecar `.env` must have: Sidecar `.env` must have:
```bash ```bash
PLATFORM_GRAPHQL_URL=... # Platform GraphQL endpoint PLATFORM_GRAPHQL_URL=https://ramaiah.app.healix360.net/graphql
PLATFORM_API_KEY=... # Platform API key for server-to-server calls PLATFORM_API_KEY=<Ramaiah workspace API key>
PLATFORM_WORKSPACE_SUBDOMAIN=fortytwo-dev PLATFORM_WORKSPACE_SUBDOMAIN=ramaiah
REDIS_URL=redis://localhost:6379 # Local Redis required 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 ### Pre-deploy checklist
Before running `deploy.sh`: 1. `npx tsc --noEmit` — passes (frontend)
1. `npx tsc --noEmit` — passes with no errors (frontend)
2. `npm run build` — succeeds (sidecar) 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 4. Check `package.json` for new dependencies → decides quick vs full deploy
--- ---
## Deployment ## Deployment
### Prerequisites (local machine) ### Frontend
Each tenant has its own frontend directory on EC2. The `VITE_API_URL` is baked at build time so each build points to the correct sidecar.
```bash ```bash
# Required tools # Helper — reuse in all commands below
brew install sshpass # SSH with password EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
aws configure # AWS CLI (for ECR) EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
docker desktop # Docker with buildx
# Verify AWS access cd helix-engage
aws sts get-caller-identity # Should show account 043728036361
# ── Ramaiah (production pilot — deploy stable builds only) ──
VITE_API_URL=https://ramaiah.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-ramaiah/
# ── Global (staging — new features land here first) ──
VITE_API_URL=https://global.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-global/
``` ```
### Path 1: Quick Deploy (no new dependencies) | Tenant | Frontend Dir | API URL (baked) | Caddy Root |
|---|---|---|---|
| Ramaiah | `/opt/fortytwo/frontend-ramaiah/` | `https://ramaiah.engage.healix360.net` | `/srv/engage-ramaiah` |
| Global | `/opt/fortytwo/frontend-global/` | `https://global.engage.healix360.net` | `/srv/engage-global` |
Use when only code changes — no new npm packages. **Important:** Always build with the correct `VITE_API_URL` for the target tenant. A build without it (or with `localhost`) will break login and API calls.
### Sidecar
```bash ```bash
cd /path/to/fortytwo-eap cd helix-engage-server
# Deploy frontend only
bash deploy.sh frontend
# 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 # 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 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 # 2. Build and push Docker image
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \ -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
--push . --push .
# 3. Pull and restart on VPS # 3. Pull and restart on EC2
ECR_TOKEN=$(aws ecr get-login-password --region ap-south-1) eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
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
"
``` ```
### How to decide which path ### How to decide
``` ```
Did package.json change? Did package.json change?
├── YES → Path 2 (ECR build + push + pull) ├── YES → ECR build + push + pull (above)
└── NO → Path 1 (deploy.sh) └── 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 ## Checking Logs
### Sidecar logs
```bash ```bash
# SSH into VPS first, or run remotely: # Ramaiah sidecar
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 30" ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 30 2>&1"
# Follow live # 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 # Filter errors
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 100 2>&1 | grep -i error" 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 # Telephony dispatcher
bash deploy.sh logs 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 Look for these in sidecar logs:
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:
``` ```
[NestApplication] Nest application successfully started [NestApplication] Nest application successfully started
Helix Engage Server running on port 4100 Helix Engage Server running on port 4100
[SessionService] Redis connected [SessionService] Redis connected
[ThemeService] Theme loaded from file (or "Using default theme")
[RulesStorageService] Initialized empty rules config
``` ```
### Common failure patterns ### Common failure patterns
| Log pattern | Meaning | Fix | | Log pattern | Meaning | Fix |
|---|---|---| |---|---|---|
| `Cannot find module 'xxx'` | Missing npm dependency | Path 2 deploy (rebuild ECR image) | | `Cannot find module 'xxx'` | Missing npm dependency | Rebuild ECR image |
| `UndefinedModuleException` | Circular dependency or missing import | Fix code, redeploy | | `UndefinedModuleException` | Circular dependency | Fix code, redeploy |
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose restart redis sidecar` | | `ECONNREFUSED redis:6379` | Redis not ready | `docker compose up -d redis-ramaiah` |
| `Forbidden resource` | Platform permission issue | Check user roles | | `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 ## Redis Operations
### Clear caller resolution cache
```bash ```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 # Clear agent session lock (fixes "already logged in from another device")
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" $SSH "$REDIS DEL agent:session:ramaiahadmin"
```
### Clear recording analysis cache # List all keys
$SSH "$REDIS KEYS '*'"
```bash # Clear caller cache (stale patient names)
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" $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 # Clear recording analysis cache
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" $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 # Nuclear: flush all sidecar Redis
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli FLUSHDB" $SSH "$REDIS FLUSHDB"
``` ```
--- ---
@@ -329,7 +312,8 @@ sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-stagin
## Database Access ## Database Access
```bash ```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 ### Useful queries
@@ -354,70 +338,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: Single-session enforcement per Ozonetel agent. Clear the lock:
1. Checkout the previous git commit ```bash
2. `npm run build` ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
3. `bash deploy.sh frontend` "docker exec ramaiah-prod-redis-ramaiah-1 redis-cli DEL agent:session:ramaiahadmin"
```
### Sidecar rollback (quick deploy) ### Agent stuck in ACW / Wrapping Up
Same as frontend — checkout previous commit, rebuild, redeploy.
### Sidecar rollback (ECR)
```bash ```bash
# Tag the current image as rollback curl -X POST https://ramaiah.engage.healix360.net/api/maint/force-ready \
# Then re-tag the previous image as :alpha -H "Content-Type: application/json" \
# Or use a specific tag/digest -d '{"agentId": "ramaiahadmin"}'
```
# On VPS: ### Telephony events not routing
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
cd /opt/fortytwo ```bash
docker compose restart sidecar # 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 ### Frontend
```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/config/theme | python3 -m json.tool
```
### Reset theme to defaults Checkout previous commit → `npm run build` → rsync to EC2.
```bash
curl -s -X POST https://engage-api.srv1477139.hstgr.cloud/api/config/theme/reset | python3 -m json.tool
```
### Theme backups ### Sidecar
Stored on the sidecar container at `/app/data/theme-backups/`. Each save creates a timestamped backup.
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 ## Git Repositories
| Repo | Azure DevOps URL | Branch | | Repo | Azure DevOps | Branch |
|---|---|---| |---|---|---|
| Frontend | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage` | `dev` | | Frontend | `helix-engage` in Patient Engagement Platform | `feature/omnichannel-widget` |
| Sidecar | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server` | `dev` | | Sidecar | `helix-engage-server` in Patient Engagement Platform | `master` |
| SDK App | `FortyTwoApps/helix-engage/` (in fortytwo-eap monorepo) | `dev` | | SDK App | `FortyTwoApps/helix-engage/` (monorepo) | `dev` |
| Telephony | `helix-engage-telephony` in Patient Engagement Platform | `master` |
### 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
```
--- ---
@@ -425,7 +407,7 @@ git add -A && git commit -m "feat: description" && git push origin dev
| Detail | Value | | Detail | Value |
|---|---| |---|---|
| Registry | 043728036361.dkr.ecr.ap-south-1.amazonaws.com | | Registry | `043728036361.dkr.ecr.ap-south-1.amazonaws.com` |
| Repository | fortytwo-eap/helix-engage-sidecar | | Sidecar repo | `fortytwo-eap/helix-engage-sidecar` |
| Tag | alpha | | Tag | `alpha` |
| Region | ap-south-1 (Mumbai) | | Region | `ap-south-1` (Mumbai) |

View File

@@ -0,0 +1,612 @@
/**
* Helix Engage — Weekly Update (Apr 611, 2026)
* "Clinical Precision" design — dark/light alternating, geometric, executive healthcare
*/
const PptxGenJS = require("pptxgenjs");
// ── Design System ───────────────────────────────────────────────
const P = {
// Dark palette (hero slides)
navyDeep: "0F172A", // slate-900
navyMid: "1E293B", // slate-800
navyLight: "334155", // slate-700
// Light palette (content slides)
white: "FFFFFF",
snow: "F8FAFC", // slate-50
mist: "F1F5F9", // slate-100
silver: "E2E8F0", // slate-200
// Text
inkDark: "0F172A",
inkMid: "475569", // slate-600
inkLight: "94A3B8", // slate-400
inkOnDark: "F1F5F9",
inkMuted: "64748B", // slate-500
// Accents — healthcare-inspired
teal: "0D9488", // primary brand
tealLight: "14B8A6",
tealPale: "CCFBF1", // teal-100
blue: "0284C7", // sky-600
blueLight: "38BDF8",
indigo: "4F46E5",
amber: "D97706",
rose: "E11D48",
emerald: "059669",
violet: "7C3AED",
};
const F = "Calibri"; // Clean, universally available
const FB = "Calibri Light";
// ── Helpers ─────────────────────────────────────────────────────
function sn(s, n) {
s.addText(`${n}`, {
x: 9.3, y: 5.15, w: 0.5, h: 0.3,
fontSize: 8, color: P.inkLight, fontFace: FB, align: "right",
});
}
function darkSlide(pptx) {
const s = pptx.addSlide();
s.background = { color: P.navyDeep };
return s;
}
function lightSlide(pptx) {
const s = pptx.addSlide();
s.background = { color: P.white };
return s;
}
// Thin teal accent line at top
function topLine(s, color) {
s.addShape("rect", { x: 0, y: 0, w: 10, h: 0.04, fill: { color: color || P.teal } });
}
// Section label pill
function pill(s, text, color, x, y) {
const w = text.length * 0.075 + 0.5;
s.addShape("roundRect", {
x, y, w, h: 0.26,
fill: { color, transparency: 85 },
rectRadius: 0.13,
});
s.addText(text.toUpperCase(), {
x, y, w, h: 0.26,
fontSize: 7, fontFace: F, bold: true, color,
align: "center", valign: "middle",
});
}
// Metric block (for dark slides)
function metric(s, { x, y, value, label, color, w = 2.0 }) {
// Subtle card
s.addShape("roundRect", {
x, y, w, h: 1.4,
fill: { color: P.navyMid },
line: { color: P.navyLight, width: 0.5 },
rectRadius: 0.08,
});
// Accent top bar
s.addShape("rect", { x: x + 0.15, y: y + 0.06, w: w - 0.3, h: 0.025, fill: { color } });
// Value
s.addText(value, {
x, y: y + 0.15, w, h: 0.75,
fontSize: 38, fontFace: F, bold: true, color,
align: "center", valign: "middle",
});
// Label
s.addText(label, {
x, y: y + 0.9, w, h: 0.35,
fontSize: 9, fontFace: FB, color: P.inkLight,
align: "center", valign: "top",
});
}
// Content card (for light slides)
function card(s, { x, y, w, h, title, accent, items }) {
// Card with left accent border
s.addShape("roundRect", {
x, y, w, h,
fill: { color: P.snow },
line: { color: P.silver, width: 0.5 },
rectRadius: 0.06,
});
// Left accent bar
s.addShape("rect", { x, y: y + 0.1, w: 0.035, h: h - 0.2, fill: { color: accent } });
// Title
s.addText(title, {
x: x + 0.25, y: y + 0.08, w: w - 0.4, h: 0.32,
fontSize: 10.5, fontFace: F, bold: true, color: accent,
});
// Items
if (items?.length) {
s.addText(
items.map(t => ({
text: t,
options: {
fontSize: 8.5, fontFace: FB, color: P.inkMid,
bullet: { code: "2022" }, // bullet dot
paraSpaceAfter: 3, breakLine: true,
},
})),
{ x: x + 0.25, y: y + 0.4, w: w - 0.5, h: h - 0.5, valign: "top", lineSpacingMultiple: 1.15 }
);
}
}
// Section heading for light slides
function sectionHead(s, title, subtitle) {
s.addText(title, {
x: 0.6, y: 0.35, w: 8, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.inkDark,
});
if (subtitle) {
s.addText(subtitle, {
x: 0.6, y: 0.78, w: 8, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkMuted,
});
}
}
// ═════════════════════════════════════════════════════════════════
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Apr 611, 2026)";
// ─── SLIDE 1: Title (Dark) ────────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
// Geometric accent — vertical teal line
s.addShape("rect", { x: 0.6, y: 1.2, w: 0.035, h: 2.8, fill: { color: P.teal } });
pill(s, "Weekly Status", P.tealLight, 0.85, 1.3);
s.addText("Helix Engage", {
x: 0.85, y: 1.7, w: 7, h: 0.9,
fontSize: 42, fontFace: F, bold: true, color: P.white,
});
s.addText("Engineering Progress Report", {
x: 0.85, y: 2.5, w: 7, h: 0.4,
fontSize: 16, fontFace: FB, color: P.inkLight,
});
// Date block
s.addShape("rect", { x: 0.85, y: 3.2, w: 2.2, h: 0.04, fill: { color: P.teal, transparency: 50 } });
s.addText("April 6 11, 2026", {
x: 0.85, y: 3.35, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.tealLight,
});
s.addText("Satya Suman Sari | FortyTwo Platform", {
x: 0.85, y: 4.8, w: 5, h: 0.25,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 1);
}
// ─── SLIDE 2: At a Glance (Dark) ─────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
pill(s, "Overview", P.tealLight, 0.5, 0.3);
s.addText("Week at a Glance", {
x: 0.5, y: 0.6, w: 5, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
metric(s, { x: 0.5, y: 1.25, value: "57", label: "Commits Shipped", color: P.blueLight, w: 2.05 });
metric(s, { x: 2.7, y: 1.25, value: "9", label: "Defects Resolved", color: P.rose, w: 2.05 });
metric(s, { x: 4.9, y: 1.25, value: "40", label: "E2E Tests Passing", color: P.emerald, w: 2.05 });
metric(s, { x: 7.1, y: 1.25, value: "17", label: "Docker Containers", color: P.violet, w: 2.05 });
// Key highlights
const highlights = [
"Multi-tenant EC2 architecture deployed — Ramaiah + Global on single instance",
"Woodpecker CI/CD pipeline operational with Teams notifications",
"Cross-tenant security vulnerability identified and patched",
"Complete documentation: architecture, runbook, CI/CD guide",
];
s.addText(
highlights.map(h => ({
text: h,
options: {
fontSize: 10, fontFace: FB, color: P.inkOnDark,
bullet: { code: "25B8" }, paraSpaceAfter: 6, breakLine: true,
},
})),
{ x: 0.6, y: 2.9, w: 8.5, h: 2.0, valign: "top", lineSpacingMultiple: 1.2 }
);
sn(s, 2);
}
// ─── SLIDE 3: Defect Fixes (Light) ────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.rose);
sectionHead(s, "Defect Resolution", "9 of 17 triaged bugs fixed and deployed this week");
const bugs = [
["#527", "Appointment creation overwrites patient details"],
["#529", "Break/Training status doesn't block outbound calls"],
["#531", "Agent can log out during an active call"],
["#533", "Redundant Call History page header"],
["#534", "Redundant Patients page header"],
["#536", "My Performance displays wrong agent data"],
["#538", "Supervisor dashboard metrics incorrect"],
["#540", "Ghost calls visible for logged-out agents"],
["#547", "SLA priority rules not reflected in worklist"],
];
const rows = [
[
{ text: "ID", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
{ text: "Description", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
{ text: "Status", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
],
...bugs.map(([id, desc], i) => [
{ text: id, options: { fontSize: 8.5, fontFace: F, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
{ text: desc, options: { fontSize: 8.5, fontFace: FB, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
{ text: "Resolved", options: { fontSize: 8.5, fontFace: F, bold: true, color: P.emerald, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
]),
];
s.addTable(rows, {
x: 0.5, y: 1.2, w: 9.0,
border: { type: "solid", pt: 0.3, color: P.silver },
colW: [0.7, 6.6, 1.7], rowH: 0.36,
});
s.addText("Deferred by product: #516 recordings | #517 AI transcription | #519 supervisor calling | #539 real-time missed calls | #541 whisper/barge", {
x: 0.5, y: 4.9, w: 9, h: 0.3,
fontSize: 7.5, fontFace: FB, color: P.inkLight, italic: true,
});
sn(s, 3);
}
// ─── SLIDE 4: Security Fix (Dark) ────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.rose);
pill(s, "Security", P.rose, 0.5, 0.3);
s.addText("Cross-Tenant Isolation Vulnerability", {
x: 0.5, y: 0.6, w: 9, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
s.addText("Discovered and patched within the same sprint", {
x: 0.5, y: 1.0, w: 9, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkLight,
});
// Problem
s.addShape("roundRect", {
x: 0.4, y: 1.5, w: 4.4, h: 2.6,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.rose } });
s.addText("Impact", {
x: 0.65, y: 1.55, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.rose,
});
s.addText(
[
"Shared OZONETEL_AGENT_ID env var across sidecars",
"6 endpoints used silent fallback to wrong agent",
"Ramaiah operations could modify Global's session",
"Agent state, disposition, dial, metrics all affected",
"No error or warning — completely silent",
].map(t => ({
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
})),
{ x: 0.65, y: 1.9, w: 3.9, h: 2.0, valign: "top" }
);
// Resolution
s.addShape("roundRect", {
x: 5.1, y: 1.5, w: 4.5, h: 2.6,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.emerald } });
s.addText("Resolution", {
x: 5.35, y: 1.55, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.emerald,
});
s.addText(
[
"Removed all defaultAgentId fallbacks",
"All 6 endpoints now require agentId (400 if absent)",
"Frontend sends agentId from localStorage",
"OZONETEL_AGENT_ID removed from config entirely",
"Verified with 40 E2E tests — zero regressions",
].map(t => ({
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
})),
{ x: 5.35, y: 1.9, w: 4.0, h: 2.0, valign: "top" }
);
// Clean layers footer
s.addText("Unaffected layers: Login (DB lookup) | Telephony dispatcher (event payload) | Sidecar registration (GraphQL) | Supervisor (webhook events)", {
x: 0.5, y: 4.4, w: 9, h: 0.3,
fontSize: 7.5, fontFace: FB, color: P.inkLight,
});
sn(s, 4);
}
// ─── SLIDE 5: EC2 Architecture (Light) ────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.blue);
sectionHead(s, "AWS EC2 Multi-Tenant Architecture", "Single instance, strict tenant isolation, host-routed Caddy");
card(s, {
x: 0.4, y: 1.2, w: 4.4, h: 2.0,
title: "Shared Platform Layer", accent: P.blue,
items: [
"NestJS server — multi-tenant by Origin header",
"PostgreSQL 16 with workspace-per-schema",
"BullMQ worker, ClickHouse analytics, Redpanda events",
"MinIO S3-compatible object storage",
],
});
card(s, {
x: 5.1, y: 1.2, w: 4.5, h: 2.0,
title: "Isolated Sidecar Layer", accent: P.amber,
items: [
"Per-hospital: sidecar + Redis + data volume",
"Caddy host-routes — no catchall, no cross-tenant",
"ramaiah.engage.healix360.net \u2192 sidecar-ramaiah",
"global.engage.healix360.net \u2192 sidecar-global",
],
});
card(s, {
x: 0.4, y: 3.4, w: 4.4, h: 1.7,
title: "Telephony Dispatcher", accent: P.teal,
items: [
"Routes Ozonetel events by agentId via Redis lookup",
"Sidecars self-register on boot with heartbeat",
"Zero config when onboarding new hospitals",
],
});
card(s, {
x: 5.1, y: 3.4, w: 4.5, h: 1.7,
title: "Live Endpoints", accent: P.indigo,
items: [
"ramaiah.engage / global.engage — Hospital UIs",
"telephony.engage — Event dispatcher",
"operations — CI/CD dashboard",
"git — Gitea forge (mirrors Azure DevOps)",
],
});
sn(s, 5);
}
// ─── SLIDE 6: E2E Tests (Dark) ────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.emerald);
pill(s, "Quality Assurance", P.emerald, 0.5, 0.3);
s.addText("40 Automated E2E Tests", {
x: 0.5, y: 0.6, w: 9, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
s.addText("Playwright smoke tests covering every page across both hospitals", {
x: 0.5, y: 1.0, w: 9, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkLight,
});
// Ramaiah
s.addShape("roundRect", {
x: 0.4, y: 1.5, w: 4.4, h: 2.4,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.amber } });
s.addText("Ramaiah Hospitals — 27 tests", {
x: 0.65, y: 1.55, w: 4, h: 0.3,
fontSize: 10.5, fontFace: F, bold: true, color: P.amber,
});
s.addText(
[
"Login flow: branding, credentials, auth guard (4)",
"CC Agent: call desk, history, patients, appointments, performance, sidebar, sign-out (10)",
"Supervisor: dashboard, team perf, live monitor, all data pages, settings (12)",
"Auth setup with auto session unlock (1)",
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
{ x: 0.65, y: 1.9, w: 3.9, h: 1.8, valign: "top" }
);
// Global
s.addShape("roundRect", {
x: 5.1, y: 1.5, w: 4.5, h: 2.4,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.blueLight } });
s.addText("Global Hospital — 13 tests", {
x: 5.35, y: 1.55, w: 4, h: 0.3,
fontSize: 10.5, fontFace: F, bold: true, color: P.blueLight,
});
s.addText(
[
"CC Agent: landing, history, patients, appointments, performance, sidebar, sign-out (7)",
"Supervisor: landing, patients, appointments, campaigns, settings (5)",
"Auth setup with auto session unlock (1)",
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
{ x: 5.35, y: 1.9, w: 4.0, h: 1.8, valign: "top" }
);
// Self-healing footer
s.addShape("roundRect", {
x: 0.4, y: 4.15, w: 9.2, h: 0.85,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addText("Self-Healing", {
x: 0.65, y: 4.2, w: 2, h: 0.25,
fontSize: 9, fontFace: F, bold: true, color: P.emerald,
});
s.addText("Auto-clears session locks before login | Completes sign-out after tests | Runs against live EC2, not mocked | ~6 min on Woodpecker CI", {
x: 0.65, y: 4.5, w: 8.5, h: 0.3,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 6);
}
// ─── SLIDE 7: CI/CD (Light) ───────────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.indigo);
sectionHead(s, "CI/CD Pipeline", "Automated testing, report publishing, and team notifications");
// Flow bar
s.addShape("roundRect", {
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
fill: { color: P.mist }, line: { color: P.silver, width: 0.5 }, rectRadius: 0.06,
});
s.addText("Azure DevOps \u2192 Gitea Mirror \u2192 Woodpecker Pipeline \u2192 MinIO Reports \u2192 Teams Alert", {
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
fontSize: 9.5, fontFace: F, bold: true, color: P.indigo, align: "center", valign: "middle",
});
card(s, {
x: 0.4, y: 1.75, w: 4.4, h: 1.7,
title: "Frontend Pipeline", accent: P.blue,
items: [
"TypeScript typecheck (yarn tsc --noEmit)",
"40 Playwright E2E tests against live EC2",
"HTML report uploaded to MinIO (S3 plugin)",
"Teams Adaptive Card with report link",
],
});
card(s, {
x: 5.1, y: 1.75, w: 4.5, h: 1.7,
title: "Sidecar Pipeline", accent: P.violet,
items: [
"Jest unit tests (npm ci + jest --ci)",
"Teams notification on pass or fail",
"Triggered on push or manual run",
],
});
card(s, {
x: 0.4, y: 3.65, w: 9.2, h: 1.4,
title: "Operations Dashboard", accent: P.teal,
items: [
"operations.healix360.net — Woodpecker CI with full build history and logs",
"operations.healix360.net/reports/{run}/ — Playwright HTML reports with screenshots (basic auth protected)",
"git.healix360.net — Gitea forge mirroring Azure DevOps every 15 minutes",
"Teams 'Deployment updates' channel receives Adaptive Cards with pass/fail count and report link",
],
});
sn(s, 7);
}
// ─── SLIDE 8: Timeline (Light) ────────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.teal);
sectionHead(s, "Development Timeline");
const timeline = [
{ date: "Apr 6 Sun", title: "Onboarding Wizard", desc: "6-phase setup wizard, widget config, telephony/AI CRUD, team invite, clinic/doctor management", color: P.blue },
{ date: "Apr 7 Mon", title: "SIP & ACW Fixes", desc: "3-layer ACW protection, SIP disconnect guard, dispose agentId, setup wizard polish", color: P.teal },
{ date: "Apr 8 Tue", title: "Master Data", desc: "Dynamic clinic/doctor fetching, appointment form overhaul, Ramaiah 195 doctor seed", color: P.amber },
{ date: "Apr 9 Wed", title: "EC2 Deployment", desc: "Multi-tenant architecture, telephony dispatcher, Caddy host routing, 14 containers", color: P.indigo },
{ date: "Apr 10 Thu", title: "Defect Sprint", desc: "9 bugs fixed, 40 E2E tests, architecture docs, runbook, cross-tenant discovery", color: P.rose },
{ date: "Apr 11 Fri", title: "CI/CD Pipeline", desc: "Woodpecker + Gitea + MinIO, Teams notifications, defaultAgentId security patch", color: P.emerald },
];
// Vertical line
s.addShape("rect", { x: 1.25, y: 1.2, w: 0.02, h: 3.9, fill: { color: P.silver } });
timeline.forEach((e, i) => {
const y = 1.2 + i * 0.65;
// Dot
s.addShape("ellipse", {
x: 1.18, y: y + 0.06, w: 0.16, h: 0.16,
fill: { color: e.color }, line: { color: P.white, width: 2 },
});
// Date
s.addText(e.date, {
x: 1.55, y, w: 1.2, h: 0.22,
fontSize: 7.5, fontFace: F, bold: true, color: e.color,
});
// Title
s.addText(e.title, {
x: 2.8, y, w: 1.8, h: 0.22,
fontSize: 9.5, fontFace: F, bold: true, color: P.inkDark,
});
// Desc
s.addText(e.desc, {
x: 4.7, y, w: 4.8, h: 0.55,
fontSize: 8, fontFace: FB, color: P.inkMid, valign: "top",
});
});
sn(s, 8);
}
// ─── SLIDE 9: Closing (Dark) ──────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
s.addShape("rect", { x: 0.6, y: 1.6, w: 0.035, h: 1.8, fill: { color: P.teal } });
s.addText("57 commits across 3 repositories", {
x: 0.85, y: 1.6, w: 8, h: 0.6,
fontSize: 28, fontFace: F, bold: true, color: P.white,
});
s.addText("From single-tenant VPS to multi-tenant EC2 with automated CI/CD,\n40 end-to-end tests, and a fully integrated operations dashboard.", {
x: 0.85, y: 2.3, w: 7, h: 0.7,
fontSize: 12, fontFace: FB, color: P.inkLight, lineSpacingMultiple: 1.4,
});
// Achievement pills
const items = [
{ text: "Multi-Tenant EC2", color: P.blue },
{ text: "40 E2E Tests", color: P.emerald },
{ text: "CI/CD Pipeline", color: P.indigo },
{ text: "9 Bugs Fixed", color: P.rose },
{ text: "Teams Alerts", color: P.violet },
];
items.forEach((a, i) => {
const x = 0.85 + i * 1.7;
s.addShape("roundRect", {
x, y: 3.4, w: 1.5, h: 0.32,
fill: { color: P.navyMid },
line: { color: a.color, width: 1 },
rectRadius: 0.16,
});
s.addText(a.text, {
x, y: 3.4, w: 1.5, h: 0.32,
fontSize: 8, fontFace: F, bold: true, color: a.color,
align: "center", valign: "middle",
});
});
s.addText("Satya Suman Sari | FortyTwo Platform", {
x: 0.85, y: 4.8, w: 5, h: 0.25,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 9);
}
await pptx.writeFile({ fileName: "docs/weekly-update-apr06-11.pptx" });
console.log("Generated: docs/weekly-update-apr06-11.pptx");
}
build().catch(err => { console.error(err); process.exit(1); });

View File

@@ -0,0 +1,102 @@
# Ozonetel CDR API Reference
> Source: [Ozonetel docs](https://docs.ozonetel.com/reference/get_ca-reports-fetchcdrdetails)
## Endpoints
| Endpoint | Path | Use Case |
|----------|------|----------|
| Fetch CDR Detailed | `GET /ca_reports/fetchCDRDetails` | All CDR for a single day |
| Fetch CDR by UCID | `GET /ca_reports/fetchCdrByUCID` | Single call lookup by UCID |
| Fetch CDR Paginated | `GET /ca_reports/fetchCdrByPagination` | Paginated CDR with `totalCount` |
## Common Constraints
- **Auth**: Bearer token (via `POST /ca_apis/caToken/generateToken`)
- **Rate limit**: 2 requests per minute (all CDR endpoints)
- **Date range**: Single day only (`fromDate` and `toDate` must be same date)
- **Lookback**: 15 days maximum from time of request
- **Mandatory params**: `fromDate`, `toDate`, `userName` (+ `ucid` for UCID endpoint)
- **Date format**: `YYYY-MM-DD HH:MM:SS`
## Domain
- Domestic: `in1-ccaas-api.ozonetel.com`
- International: `api.ccaas.ozonetel.com`
## CDR Record Fields (42 fields)
| Field | Type | Description | Sidecar Status |
|-------|------|-------------|----------------|
| `AgentDialStatus` | string | Agent's dial attempt status (e.g., "answered") | Not mapped |
| `AgentID` | string | Agent identifier | **Mapped** — filter CDR by agent |
| `AgentName` | string | Agent name | **Mapped** — fallback filter |
| `CallAudio` | string | URL to call recording (S3) | Not mapped (recording via platform) |
| `CallDate` | string | Date of call (YYYY-MM-DD) | Not mapped |
| `CallID` | number | Unique call identifier | Not mapped |
| `CallerConfAudioFile` | string | Conference audio file | Not mapped |
| `CallerID` | string | Caller's phone number | Not mapped |
| `CampaignName` | string | Associated campaign name | Not mapped — **available for US-15** |
| `Comments` | string | Additional comments | Not mapped |
| `ConferenceDuration` | string | Conference duration (HH:MM:SS) | Not mapped |
| `CustomerDialStatus` | string | Customer dial status | Not mapped |
| `CustomerRingTime` | string | Customer phone ring time | Not mapped — **missed call analysis** |
| `DID` | string | Direct inward dial number | Not mapped — **available for US-2 branch display** |
| `DialOutName` | string | Dialed party name | Not mapped |
| `DialStatus` | string | Overall dial status | Not mapped |
| `DialedNumber` | string | Phone number dialed | Not mapped |
| `Disposition` | string | Call disposition/outcome | **Mapped** — disposition breakdown |
| `Duration` | string | Total call duration | Not mapped |
| `DynamicDID` | string | Dynamic DID reference | Not mapped |
| `E164` | string | E.164 formatted phone number | Not mapped |
| `EndTime` | string | Call end time | Not mapped |
| `Event` | string | Event type (e.g., "AgentDial") | Not mapped |
| `HandlingTime` | string/null | Total handling time — **CAN BE NULL** | Not mapped — **available for US-13 avg handling** |
| `HangupBy` | string | Who terminated call | Not mapped |
| `HoldDuration` | string | Time on hold | Not mapped — **available for US-12** |
| `Location` | string | Caller location | Not mapped |
| `PickupTime` | string | When call was answered | Not mapped |
| `Rating` | number | Call quality rating | Not mapped |
| `RatingComments` | string | Rating comments | Not mapped |
| `Skill` | string | Agent skill/queue name | Not mapped |
| `StartTime` | string | Call start time | Not mapped |
| `Status` | string | Call status (Answered/NotAnswered) | **Mapped** — inbound/missed split |
| `TalkTime` | string | Active talk duration | **Mapped** — avg talk time calc |
| `TimeToAnswer` | string | Duration until answer | Not mapped — **available for lead response KPI** |
| `TransferType` | string | Type of transfer | Not mapped — **available for US-3 audit** |
| `TransferredTo` / `TransferTo` | string | Transfer target — **field name varies by endpoint** | Not mapped |
| `Type` | string | Call type (InBound/Manual/Progressive) | **Mapped** — inbound/outbound split |
| `UCID` | number | Unique call identifier | Not mapped |
| `UUI` | string | User-to-user information | Not mapped |
| `WrapUpEndTime` | string/null | Wrapup completion time — **CAN BE NULL** | Not mapped |
| `WrapUpStartTime` | string/null | Wrapup start time — **CAN BE NULL** | Not mapped |
| `WrapupDuration` | string/null | Wrapup duration — **CAN BE NULL** | Not mapped — **available for US-12** |
## Pagination Endpoint Extra Fields
| Field | Description |
|-------|-------------|
| `totalCount` | Total number of records matching the query |
## Known Issues / Gotchas
1. **`HandlingTime`, `WrapupDuration`, `WrapUpStartTime`, `WrapUpEndTime` can be `null`** — when agent didn't complete wrapup (seen in UCID endpoint example). Code must null-guard these.
2. **Field name inconsistency**: `TransferredTo` in fetchCDRDetails vs `TransferTo` in pagination endpoint. Handle both.
3. **`WrapUpEndTime` vs `WrapupEndTime`**: casing differs between endpoints (camelCase vs mixed). Handle both.
4. **Single-day constraint**: `fromDate` and `toDate` must be the same date. For multi-day range, call once per day.
5. **Rate limit 2 req/min**: For a 7-day weekly report that needs CDR + summary per day = 14 API calls = 7 minutes minimum. Consider caching daily results.
## Current Sidecar Usage
**Endpoint used**: `fetchCDRDetails` only (in `ozonetel-agent.service.ts`)
**Fields actively mapped** (6 of 42):
- `AgentID` / `AgentName` — agent filtering
- `Type` — inbound/outbound split
- `Status` — answered/missed split
- `TalkTime` — avg talk time calculation
- `Disposition` — disposition breakdown chart
**Not yet used**:
- `fetchCdrByUCID` — useful for Patient 360 single-call drill-down
- `fetchCdrByPagination` — useful for high-volume days (current approach loads all records into memory)

View File

@@ -0,0 +1,140 @@
# AI Coaching Panel 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:** Replace the AI chat panel with a three-zone coaching surface — structured summary card, rule-driven suggestions with scripts, and contextual chat with progressive suggestion updates.
**Architecture:** CallerContextService (already built) pre-fetches caller data into Redis. Rules engine evaluates caller facts against seeded suggestion rules, producing triggers. AI system prompt includes caller context + suggestion triggers + structured output instructions. Every AI response returns `{ message, suggestions }` JSON. Frontend parses and renders across three zones.
**Tech Stack:** React 19 + Tailwind (frontend), NestJS + Vercel AI SDK + json-rules-engine + Redis (sidecar), FontAwesome Pro icons
---
## File Structure
### Sidecar (helix-engage-server)
| File | Responsibility |
|------|----------------|
| `src/rules-engine/suggestion-rules.ts` | NEW: Default suggestion rule definitions + evaluator function |
| `src/caller/caller-context.service.ts` | MODIFY: Add suggestion evaluation, render suggestions for prompt |
| `src/ai/ai-chat.controller.ts` | MODIFY: Inject suggestion rules into system prompt |
| `src/config/ai.defaults.ts` | MODIFY: Update ccAgentHelper prompt with structured JSON output format |
### Frontend (helix-engage)
| File | Responsibility |
|------|----------------|
| `src/components/call-desk/ai-summary-card.tsx` | NEW: Zone 1 patient profile card |
| `src/components/call-desk/ai-suggestions.tsx` | NEW: Zone 2 suggestion pills with expand/script/tell-me-more |
| `src/components/call-desk/ai-chat-panel.tsx` | REWRITE: Orchestrates 3 zones, parses structured JSON responses |
| `src/components/call-desk/context-panel.tsx` | MODIFY: Remove P360 tab toggle, single surface |
| `src/pages/rules-settings.tsx` | MODIFY: Display suggestion rules in Automations tab |
---
## Task 1: Suggestion Rules Engine (Sidecar)
**Files:**
- Create: `helix-engage-server/src/rules-engine/suggestion-rules.ts`
- Modify: `helix-engage-server/src/caller/caller-context.service.ts`
- [ ] **Step 1:** Create `suggestion-rules.ts` with types (`SuggestionType`, `SuggestionPriority`, `SuggestionTrigger`), department-to-package mapping, cross-sell mapping, and `evaluateSuggestionRules(facts)` function that evaluates 5 default rules: (1) package upsell by department, (2) reschedule missed appointments, (3) cross-sell related departments, (4) first-visit health checkup, (5) returning patient re-engagement. Max 4 triggers returned. Also export `SUGGESTION_RULE_DEFINITIONS` array for Settings UI display.
- [ ] **Step 2:** In `caller-context.service.ts`, add `suggestionTriggers: SuggestionTrigger[]` to the `CallerContext` type. Import `evaluateSuggestionRules`. Call it in the `build()` method after fetching all data, passing caller facts (isNew, appointments, calls, interestedService, contactAttempts, leadSource, utmCampaign). Add `renderSuggestionsForPrompt(triggers)` method that formats triggers for the AI system prompt.
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
- [ ] **Step 4:** Commit: `feat: suggestion rules engine + caller context evaluation`
---
## Task 2: Structured Output in AI System Prompt (Sidecar)
**Files:**
- Modify: `helix-engage-server/src/config/ai.defaults.ts`
- Modify: `helix-engage-server/src/ai/ai-chat.controller.ts`
- [ ] **Step 1:** In `ai.defaults.ts`, append structured output instructions to `CC_AGENT_HELPER_DEFAULT` template. The AI must respond with valid JSON: `{"message": "...", "suggestions": [{"id", "type", "title", "script", "priority"}]}`. Rules: always include suggestions on first response, update on subsequent, no markdown in message field, max 4 suggestions, personalized scripts using caller's name/doctor/department.
- [ ] **Step 2:** In `ai-chat.controller.ts` stream endpoint, after the caller context injection block, inject suggestion rules: `if (callerCtx.suggestionTriggers?.length) systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers)`
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
- [ ] **Step 4:** Commit: `feat: structured JSON output + suggestion rules in AI system prompt`
---
## Task 3: AI Summary Card Component (Frontend)
**Files:**
- Create: `helix-engage/src/components/call-desk/ai-summary-card.tsx`
- [ ] **Step 1:** Create Zone 1 component. Props: `caller: CallerSummary | null`. Renders: patient avatar + name + NEW/RETURNING badge, phone number, 2-line AI summary (line-clamped), source + campaign badges, compact appointment pills (next upcoming with green bg, last completed with gray bg). For null caller: centered placeholder text. Uses Badge component, FontAwesome icons (faUser, faCalendarCheck, faPhone).
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: AI summary card component (Zone 1)`
---
## Task 4: Suggestions Component (Frontend)
**Files:**
- Create: `helix-engage/src/components/call-desk/ai-suggestions.tsx`
- [ ] **Step 1:** Create Zone 2 component. Props: `suggestions: Suggestion[]`, `onTellMeMore: (suggestion) => void`. Exports `Suggestion` type (id, type, title, script, priority). Renders: collapsible section header "Suggestions (N)", list of compact pill cards. Each pill: type icon (faArrowUp/faTag/faRotate/faClipboardCheck), title, priority dot (red/yellow/green). Click toggles expand with script text + "Tell me more" link. Collapse/expand toggle for entire section.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: AI suggestions component (Zone 2)`
---
## Task 5: Rewrite AI Chat Panel (Frontend)
**Files:**
- Rewrite: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
- [ ] **Step 1:** Rewrite to orchestrate 3 zones. New props: `callerSummary?: CallerSummary | null`. Adds `suggestions` state managed from parsed AI responses. `parseAiResponse(content)` extracts `{ message, suggestions }` from JSON, falls back to raw text on parse failure. Zone 1: AiSummaryCard (not shown for supervisor). Zone 2: AiSuggestions with `onTellMeMore` that appends "Tell me more about X" as chat message. Zone 3: chat with `displayMessages` that strips JSON wrapper showing only the message field. Auto-fire kept. Supervisor mode unchanged (quick actions, no summary/suggestions). Keep existing MessageContent + parseLine helpers.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: rewrite AI chat panel — 3-zone coaching surface`
---
## Task 6: Wire Context Panel (Frontend)
**Files:**
- Modify: `helix-engage/src/components/call-desk/context-panel.tsx`
- [ ] **Step 1:** Remove P360 tab toggle (activeTab state, tab buttons, P360 sections — appointments list, call history list, follow-ups list). Build `callerSummary` object from `selectedLead` + `appointments` data: name, phone, isNew, aiSummary, leadSource, utmCampaign, nextAppointment (first SCHEDULED after now), lastAppointment (first COMPLETED). Pass `callerSummary` to AiChatPanel as new prop. Single surface — AiChatPanel is the only content.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: remove P360 toggle, single coaching surface`
---
## Task 7: Settings UI — Suggestion Rules Display
**Files:**
- Modify: `helix-engage/src/pages/rules-settings.tsx`
- [ ] **Step 1:** Add `SUGGESTION_RULES` array (5 items: name, category, description, enabled) to the Automations tab. Render below existing automation rules with "AI Suggestions" subheading. Same card pattern: category badge, name, description, enabled/disabled dot. All enabled, read-only.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: display suggestion rules in Settings > Automations`
---
## Task 8: Build, Deploy, Test
- [ ] **Step 1:** Build sidecar: `cd helix-engage-server && npm run build`
- [ ] **Step 2:** Build frontend: `cd helix-engage && npm run build`
- [ ] **Step 3:** Deploy sidecar to ECR + pull on EC2
- [ ] **Step 4:** Deploy frontend to EC2 via rsync + restart Caddy
- [ ] **Step 5:** Test on Tauri: rebuild frontend with Global URL, launch, trigger call. Verify: Zone 1 summary card, Zone 2 suggestions from rules, click expand shows script, "Tell me more" sends to chat, progressive suggestion updates, server logs show cache hits and no tool calls for patient data
- [ ] **Step 6:** Final commit and push both repos

1219
docs/requirements.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
# AI Coaching Panel — Design Spec
## Goal
Replace the current AI chat panel with a proactive coaching surface that shows structured patient summaries, rule-driven upsell/cross-sell/retention suggestions with clickable scripts, and a contextual chat — all in the existing 400px right-hand panel.
## Architecture
Single scrollable panel, three zones. No tabs or toggles. Caller context pre-fetched and cached in Redis (CallerContextService). Rules engine produces suggestion triggers. AI renders triggers into natural language scripts. Every AI response includes updated suggestions (progressive).
## Panel Layout
### Zone 1 — Summary Card (pinned top, ~120px)
- Patient name, age, gender, patient type badge (NEW / RETURNING)
- 2-line AI summary (from `aiSummary` field on lead record)
- Campaign badge + source tag (e.g., "Cervical Cancer Screening Drive" / "Google")
- Compact appointment pills: next upcoming appointment (date + doctor), last completed (date + outcome)
- Renders from CallerContextService data — no AI call needed for this zone
For new callers (no lead/patient): shows phone number, "New Caller" badge, and a prompt to collect name.
### Zone 2 — Suggestions (collapsible, below summary)
- 2-4 suggestion pills as compact cards
- Each pill: type icon (tag/arrow-up/rotate-cw), one-line title, priority dot (high/medium/low)
- Click expands inline with a 2-3 sentence ready-to-read script
- Expanded state has a "Tell me more" link that sends the suggestion as a chat message
- Suggestions refresh with every AI response (progressive)
- Collapse/expand toggle for the entire section ("Suggestions (3)")
Suggestion types:
- **upsell** — premium packages, add-on services
- **crosssell** — related services in other departments
- **retention** — reschedule missed appointments, follow up on lapsed visits
- **operational** — fasting reminders, insurance docs, directions
### Zone 3 — Chat (fills remaining space)
- Streaming chat, same UX as today
- Agent types questions or clicks "Tell me more" from a suggestion
- Each AI response may include updated suggestions (Zone 2 refreshes)
- Quick action pills at bottom, contextual to conversation state
- Auto-fires patient summary on call connect (existing behavior, kept)
## Structured AI Response Format
Every AI response is structured JSON (not free-form text):
```json
{
"message": "Priya Sharma is a returning patient...",
"suggestions": [
{
"id": "s1",
"type": "upsell",
"title": "Cardiac Wellness Package",
"script": "Since you're already seeing Dr. Lakshmi for cardiology, we have a comprehensive cardiac wellness package...",
"priority": "high"
},
{
"id": "s2",
"type": "retention",
"title": "Reschedule missed appointment",
"script": "I see your last appointment on April 10th was rescheduled. Would you like me to book a new slot?",
"priority": "medium"
}
]
}
```
The `message` field renders as a chat bubble in Zone 3. The `suggestions` array replaces the current set in Zone 2. If `suggestions` is empty or absent, Zone 2 retains the previous set.
The initial auto-fired response includes the summary message + first set of suggestions. Subsequent responses update suggestions based on conversation context.
## Rules Engine to AI Prompt Pipeline
### Step 1: Rules evaluation
CallerContextService already builds the caller facts (appointments, campaigns, call history, lead status, interested service). The rules engine evaluates these facts against configured suggestion rules.
Each rule produces a raw trigger:
```json
{
"type": "upsell",
"product": "cardiac-wellness-package",
"reason": "Patient has cardiology appointment, no wellness package booked",
"priority": "high"
}
```
### Step 2: Prompt injection
Raw triggers are appended to the system prompt as a `SUGGESTION RULES` section:
```
SUGGESTION RULES (from business configuration):
Based on this caller's profile, the following suggestions should be offered.
Generate a natural, conversational script for each that the agent can read aloud.
Return them in the `suggestions` array of your JSON response.
1. [upsell/high] Cardiac Wellness Package — patient has cardiology appointment, no wellness package booked
2. [retention/medium] Reschedule missed appointment — last appointment was rescheduled, no new booking
```
### Step 3: AI generates scripts
The AI turns the raw triggers into conversational scripts using the caller's context (name, history, doctor, department). Scripts are personalized, not templated.
### Step 4: Seeded rules
Default suggestion rules seeded in the rules engine config:
- Package upsell by department (cardiology → cardiac wellness, ortho → physio package)
- Reschedule missed/cancelled appointments
- Cross-sell related departments (ortho → physio, cardio → dietician)
- First-visit patient: suggest health checkup package
- Returning patient with no recent visit: re-engagement prompt
These rules are displayed read-only in Settings > Automations tab (same card pattern as existing automation rules — visible but not editable in v1).
## Data Flow
```
Call arrives
-> CallerResolutionController.resolve()
-> CallerContextService.prewarm() (parallel fetch + Redis cache)
-> Frontend auto-fires AI chat
-> POST /api/ai/stream
-> buildCallerContext() — Redis cache hit
-> rulesEngine.evaluate(callerFacts) — produces suggestion triggers
-> buildSystemPrompt(KB + callerContext + suggestionRules + structuredOutputInstructions)
-> streamText() — AI returns structured JSON { message, suggestions }
-> Frontend parses response
-> Zone 1: summary card from CallerContextService (no AI needed)
-> Zone 2: suggestions from AI response
-> Zone 3: message as chat bubble
Agent clicks "Tell me more" on a suggestion
-> Sent as chat message: "Tell me more about the Cardiac Wellness Package"
-> AI responds with detailed info + updated suggestions
-> Zone 2 refreshes with new suggestions
Agent books appointment (via disposition/form)
-> System message injected into chat: "Agent booked appointment with Dr. Lakshmi on Apr 24"
-> Next AI response reflects the action + updates suggestions
(e.g., removes "reschedule" suggestion, adds "send appointment reminder via WhatsApp")
```
## Surface Area
### Sidecar (helix-engage-server)
| File | Change |
|------|--------|
| `ai-chat.controller.ts` | Add structured output instructions to system prompt. Add suggestion rules injection from rules engine. Parse/pass suggestion triggers. |
| `caller-context.service.ts` | Add rules evaluation method that runs caller facts against suggestion rules. Return triggers alongside context. |
| `rules-engine/` | Seed default suggestion rules (JSON config in Redis or file). |
| `config/ai.defaults.ts` | Update `ccAgentHelper` prompt template with structured output format instructions and suggestion generation rules. |
### Frontend (helix-engage)
| File | Change |
|------|--------|
| NEW: `ai-summary-card.tsx` | Zone 1 — patient profile card rendered from CallerContextService data |
| NEW: `ai-suggestions.tsx` | Zone 2 — suggestion pills with expand/collapse, script display, "Tell me more" |
| REWRITE: `ai-chat-panel.tsx` | Orchestrates all 3 zones. Parses structured JSON responses. Manages suggestion state. Passes "Tell me more" clicks as chat messages. |
| `context-panel.tsx` | Remove P360 tab toggle. Single surface — AI coaching panel is the only mode. |
### No changes needed
- `call-desk.tsx` — panel wrapper stays the same
- `app-shell.tsx` — no changes
- `CallerContextService` — already built, just add rules evaluation call
- Frontend build pipeline — no new dependencies
## What this replaces
- P360 context tab (appointments, call history, follow-ups tables) — replaced by AI summary card
- AI chat toggle — removed (single surface)
- Tool-based patient lookups during chat — replaced by pre-fetched context in KB
- Static quick action pills — replaced by rule-driven contextual suggestions
## Out of scope for v1
- Editable suggestion rules UI (shown read-only in Settings)
- Supervisor AI coaching (different tool set, different panel)
- Real-time transcript-driven suggestions (requires live call transcription)
- Suggestion analytics (which suggestions agents click, conversion tracking)

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`.

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,385 @@
# Supervisor Barge / Whisper / Listen — Design Spec
**Date:** 2026-04-12
**Branch:** `feature/barge-whisper`
**Prereq:** QA validates barge flow in Ozonetel's own admin UI first
---
## Overview
Enable supervisors to monitor and intervene in live agent calls directly from Helix Engage's live monitor. Three modes: **Listen** (silent), **Whisper** (agent hears supervisor, patient doesn't), **Barge** (both hear supervisor). Supervisor connects via SIP WebRTC in the browser. Mode switching via DTMF tones.
## Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Connection method | SIP only (PSTN later) | Supervisors are already on browser with headset |
| Agent indicator | Whisper/barge only (listen is silent) | Spec says show indicator; listen should be undetectable |
| SIP number | Dynamic from Ozonetel pool (apiId 139) | No need to pre-assign per supervisor. 3 SIP IDs available. |
| Barge UI location | Live monitor + context panel + barge controls | Supervisor needs call context to intervene effectively |
| Access control | Any admin can barge any agent | Flat RBAC, no team hierarchy |
| Call end behavior | Auto-disconnect supervisor | No orphaned sessions |
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Supervisor Browser │
│ │
│ ┌──────────────┐ ┌────────────────────────────────┐ │
│ │ Live Monitor │ │ Context Panel + Barge Controls│ │
│ │ │ │ │ │
│ │ Agent list │ │ Patient summary / AI insight │ │
│ │ Active calls │──│ Appointments / Recent calls │ │
│ │ Click → │ │ ─────────────────────────────│ │
│ │ │ │ [Connect] │ │
│ │ │ │ [Listen] [Whisper] [Barge] │ │
│ │ │ │ [Hang up] │ │
│ └──────────────┘ └────────────────────────────────┘ │
│ │ │ │
│ │ poll /active-calls │ SIP WebRTC (kSip) │
│ │ every 5s │ DTMF 4/5/6 │
└─────────┼───────────────────────┼────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────────┐
│ Sidecar │ │ Ozonetel SIP Gateway │
│ │ │ (blr-pub-rtc4.ozonetel) │
│ POST /api/supervisor│ │ │
│ /barge │ │ SIP INVITE → supervisor │
│ /barge-mode │ │ audio mixing │
│ │ │ DTMF routing │
│ → Ozonetel admin API│ └──────────────────────────┘
│ dashboardApi │
│ apiId 63, 139 │
└─────────────────────┘
┌─────────────────────┐
│ Ozonetel Cloud │
│ api.cloudagent. │
│ ozonetel.com │
│ │
│ /dashboardApi/ │
│ monitor/api │
│ apiId 63 → barge │
│ apiId 139 → SIP# │
│ /auth/login → JWT │
└─────────────────────┘
```
## Components
### 1. Sidecar — Ozonetel Admin Auth Service
**New file:** `src/ozonetel/ozonetel-admin-auth.service.ts`
Manages a persistent Ozonetel admin session for supervisor APIs. Credentials from TelephonyConfig.
**Config extension** (`telephony.defaults.ts`):
```typescript
ozonetel: {
// ...existing fields
adminUsername: string; // NEW
adminPassword: string; // NEW
};
```
**Flow:**
1. On startup, read `adminUsername` + `adminPassword` from TelephonyConfig
2. `GET /api/auth/public-key``{ publicKey, keyId }`
3. RSA-encrypt credentials using `jsencrypt`
4. `POST /auth/login` → JWT token
5. Cache token in memory, decode expiry via `jwt-decode`
6. Auto-refresh before expiry
7. Expose `getAuthHeaders()` for other services
**Auth headers for all admin API calls:**
```typescript
{
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`,
'userId': userId,
'userName': userName,
'isSuperAdmin': 'true',
'dAccessType': 'false'
}
```
### 2. Sidecar — Supervisor Barge Endpoints
**New file:** `src/supervisor/supervisor-barge.controller.ts`
Three endpoints proxying to Ozonetel admin API:
#### `POST /api/supervisor/barge`
Initiates barge-in on an active call.
```typescript
// Request
{ ucid: string, agentNumber: string }
// Sidecar calls:
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
{
apiId: 63,
ucid: "<ucid>",
action: "CALL_BARGEIN",
isSip: true,
phoneno: "<dynamic SIP number from pool>",
agentNumber: "<agent phone>",
cbURL: "<sidecar hostname>"
}
// Response
{ status: "success", sipNumber: "19810", sipPassword: "19810", sipDomain: "blr-sbc1.ozonetel.com", sipPort: "442" }
```
Before calling barge, fetches an available SIP number:
#### `GET /api/supervisor/barge/sip-credentials`
```typescript
// Sidecar calls:
POST https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI/endpoint/sipnumber/sipSubscribe
{ apiId: 139, sipURL: "<sip gateway>" }
// Response
{ sip_number: "19810", password: "19810", pop_location: "blr-sbc1.ozonetel.com" }
```
#### `POST /api/supervisor/barge/end`
Cleanup: disconnect SIP, clear Redis tracking.
```typescript
// Request
{ agentId: string, sipId: string }
// Sidecar calls:
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
{ apiId: 158, Action: "delete", AgentId: "<agentId>", Sip: "<sipId>" }
```
### 3. Frontend — Supervisor SIP Client
**New file:** `src/lib/supervisor-sip-client.ts`
Lightweight SIP client for supervisor barge sessions. Modeled on Ozonetel's `kSip.tsx` — separate from the agent's `sip-client.ts`.
```typescript
type SupervisorSipClient = {
init(domain: string, port: string, number: string, password: string): void;
register(): void;
isRegistered(): boolean;
isCallActive(): boolean;
sendDTMF(digit: string): void; // "4"=listen, "5"=whisper, "6"=barge
hangup(): void;
close(): void;
on(event: string, callback: Function): void;
off(event: string, callback: Function): void;
};
```
**Events emitted:**
- `registered` — SIP registration successful
- `registrationFailed` — SIP registration error
- `callReceived` — incoming call from Ozonetel (auto-answer)
- `callConnected` — barge session active
- `callEnded` — call terminated (agent hung up or supervisor hung up)
**Audio:** Remote audio plays through a hidden `<audio>` element (same pattern as agent SIP). Supervisor's microphone is captured via `getUserMedia`.
**DTMF mode mapping:**
- `"4"` → Listen (supervisor hears all, nobody hears supervisor)
- `"5"` → Whisper/Training (agent hears supervisor, patient doesn't)
- `"6"` → Barge (both hear supervisor)
### 4. Frontend — Live Monitor Redesign
**Modified file:** `src/pages/live-monitor.tsx`
Current: full-width table with disabled barge buttons.
New: split layout — call list on the left, context panel + barge controls on the right.
**Layout:**
```
┌─────────────────────────────┬──────────────────────────────┐
│ Active Calls (left, 60%) │ Context + Barge (right, 40%)│
│ │ │
│ ┌─ KPI cards ────────────┐ │ (nothing selected) │
│ │ Active: 3 Hold: 1 │ │ "Select a call to monitor" │
│ └────────────────────────┘ │ │
│ │ ── OR ── │
│ ┌─ Table ────────────────┐ │ │
│ │ Agent Caller Type Dur│ │ ┌─ Patient Summary ───────┐ │
│ │ rekha +9180.. In 2:34│ │ │ Name / Phone / Type │ │
│ │ ▶ selected row │ │ │ AI Insight │ │
│ │ ganesh +9199.. Out 0:45│ │ │ Appointments │ │
│ └────────────────────────┘ │ │ Recent calls │ │
│ │ └─────────────────────────┘ │
│ │ │
│ │ ┌─ Barge Controls ───────┐ │
│ │ │ [Connect] │ │
│ │ │ │ │
│ │ │ (after connect:) │ │
│ │ │ [Listen] [Whisper] [Barge]│
│ │ │ status: Connected 1:23 │ │
│ │ │ [Hang up] │ │
│ │ └─────────────────────────┘ │
└─────────────────────────────┴──────────────────────────────┘
```
**Selection flow:**
1. Supervisor clicks a call row → row highlights
2. Right panel populates with caller context (fetched from platform via lead phone match)
3. "Connect" button becomes active
4. Click Connect → sidecar fetches SIP credentials → calls barge API → supervisor SIP client registers → auto-answers incoming call
5. Status: CONNECTING → CONNECTED
6. Mode tabs appear: Listen (default) / Whisper / Barge
7. Tab click sends DTMF tone via supervisor SIP client
8. Hang up → disconnect SIP, clean up, right panel resets
### 5. Frontend — Agent Barge Indicator
**Modified file:** `src/components/call-desk/active-call-card.tsx`
When supervisor switches to whisper or barge mode, the agent sees an indicator.
**Detection:** The sidecar's supervisor service emits SSE events. Add a new event type:
```typescript
// New SSE event from /api/supervisor/agent-state/stream
{ state: "supervisor-whisper", timestamp: "..." }
{ state: "supervisor-barge", timestamp: "..." }
{ state: "supervisor-left", timestamp: "..." }
```
**UI:** Small badge on the active call card:
- Whisper mode: "Supervisor coaching" badge (blue)
- Barge mode: "Supervisor on call" badge (brand)
- Listen mode: no indicator (silent)
**Implementation:** The sidecar tracks barge state per agent. When a supervisor connects and switches mode, the sidecar emits the appropriate SSE event to the agent's stream. The agent's `use-agent-state.ts` hook picks it up and sets a Recoil atom. The `active-call-card.tsx` renders the badge conditionally.
### 6. Sidecar — Barge State Tracking
**Modified file:** `src/supervisor/supervisor.service.ts`
Track which supervisor is barged into which agent, and in what mode.
```typescript
type BargeSession = {
supervisorId: string;
agentId: string;
sipNumber: string;
mode: 'listen' | 'whisper' | 'barge';
startedAt: string;
};
// In-memory map (single sidecar per hospital)
private readonly bargeSessions = new Map<string, BargeSession>();
```
When mode changes, emit SSE event to the agent:
- `listen` → no event (silent)
- `whisper` → emit `supervisor-whisper` to agent's SSE stream
- `barge` → emit `supervisor-barge` to agent's SSE stream
- disconnect → emit `supervisor-left` to agent's SSE stream
**New endpoint for mode update:**
```typescript
POST /api/supervisor/barge/mode
{ agentId: string, mode: "listen" | "whisper" | "barge" }
```
This updates the in-memory session and emits the SSE event. The actual audio routing happens via DTMF on the SIP connection (frontend handles that).
## Data Flow — Full Barge Sequence
```
1. Supervisor clicks call row in live monitor
└→ Frontend fetches caller context from platform (lead by phone match)
└→ Right panel shows patient summary
2. Supervisor clicks "Connect"
└→ Frontend: POST /api/supervisor/barge/sip-credentials
└→ Sidecar: calls Ozonetel apiId 139 → gets SIP number/password/domain
└→ Frontend: initializes supervisor-sip-client with credentials
└→ Frontend: POST /api/supervisor/barge { ucid, agentNumber }
└→ Sidecar: calls Ozonetel apiId 63 (CALL_BARGEIN, isSip: true)
└→ Ozonetel: bridges SIP number into active call
└→ Supervisor SIP client receives incoming call → auto-answers
└→ Status: CONNECTED, default mode: Listen (DTMF "4" sent)
└→ Sidecar: creates BargeSession in memory
3. Supervisor clicks "Whisper" tab
└→ Frontend: supervisor-sip-client.sendDTMF("5")
└→ Ozonetel: routes supervisor audio to agent only
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "whisper" }
└→ Sidecar: emits SSE { state: "supervisor-whisper" } to agent
└→ Agent: sees "Supervisor coaching" badge
4. Supervisor clicks "Barge" tab
└→ Frontend: supervisor-sip-client.sendDTMF("6")
└→ Ozonetel: routes supervisor audio to both
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "barge" }
└→ Sidecar: emits SSE { state: "supervisor-barge" } to agent
└→ Agent: sees "Supervisor on call" badge
5. Call ends (agent or patient hangs up)
└→ Supervisor SIP client: "callEnded" event fires
└→ Frontend: auto-disconnects, calls POST /api/supervisor/barge/end
└→ Sidecar: clears BargeSession, emits SSE { state: "supervisor-left" }
└→ Agent: badge disappears
└→ UI: right panel resets to "Select a call to monitor"
```
## Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` | Ozonetel admin JWT management |
| `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` | Barge proxy endpoints |
| `helix-engage/src/lib/supervisor-sip-client.ts` | Supervisor SIP client (modeled on kSip) |
### Modified Files
| File | Change |
|------|--------|
| `helix-engage-server/src/config/telephony.defaults.ts` | Add `adminUsername`, `adminPassword` |
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Add barge session tracking + SSE events |
| `helix-engage/src/pages/live-monitor.tsx` | Split layout, context panel, barge controls |
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Supervisor indicator badge |
| `helix-engage/src/hooks/use-agent-state.ts` | Handle supervisor SSE events |
| `helix-engage/src/components/setup/wizard-step-telephony.tsx` | Add admin credential fields |
### Reference Files (from Ozonetel source — study, don't copy)
| File | What to learn |
|------|--------------|
| `CA-Admin/.../BargeInDrawer/BargeInDrawer.tsx` | Normal barge flow, status states |
| `CA-Admin/.../BargeinDrawerSip/BargeinDrawerSip.tsx` | SIP barge, DTMF, continuous barge, session storage |
| `CA-Admin/.../utils/ksip.tsx` | SIP client wrapper pattern |
| `CA-Admin/.../services/api-service.ts:827-890` | Barge API payloads |
| `CA-Admin/.../services/auth-service.ts` | Admin auth flow |
| `cloudagent/.../services/websocket.service.js:367-460` | Agent-side barge event handling |
## Testing Plan
1. **Prereq:** QA validates barge in Ozonetel's own admin UI with the 3 SIP IDs
2. **Sidecar unit tests:** Admin auth service (login, token refresh, expiry)
3. **Sidecar integration test:** Barge endpoint → Ozonetel API (mock or live)
4. **Frontend manual test:** Connect → listen → whisper → barge → hang up
5. **Agent indicator test:** Verify badge appears on whisper/barge, disappears on listen/disconnect
6. **Auto-disconnect test:** Agent ends call → supervisor auto-disconnects
7. **Edge cases:** Supervisor navigates away mid-barge, network drop, agent goes to ACW
## Out of Scope (Future)
- PSTN barge (call supervisor's phone instead of SIP)
- Continuous barge (auto-reconnect to next call same agent handles)
- Barge audit logging (who barged whom, when, duration)
- Gemini AI whisper (separate feature, separate branch)
- Multi-supervisor on same call

View File

@@ -0,0 +1,162 @@
# Helix Engage — Weekly Status Update
**Period:** April 6 April 11, 2026
**Team:** Engineering
---
## Executive Summary
Major infrastructure milestone — Helix Engage is now running on AWS EC2 with multi-tenant architecture supporting both Ramaiah Hospitals and Global Hospital on a single instance. A full CI/CD pipeline with automated E2E testing and Teams notifications is operational. 17 defects from QA were triaged, 8 fixed and deployed, and a cross-tenant security vulnerability in the telephony layer was discovered and patched.
---
## 1. AWS EC2 Deployment (Multi-Tenant)
**Status: Live**
Migrated from single-tenant VPS to multi-tenant EC2 architecture:
- **Instance:** m6i.xlarge, Mumbai (ap-south-1), 15GB RAM
- **14 Docker containers** running: platform, 2 sidecars, telephony dispatcher, 4 Redis instances, Caddy, PostgreSQL, ClickHouse, Redpanda, MinIO
- **Strict tenant isolation:** each hospital has its own sidecar container, Redis instance, and data volume
- **Host-routed Caddy:** cross-tenant webhook routing is physically impossible
**URLs deployed:**
- ramaiah.engage.healix360.net (Ramaiah Hospitals)
- global.engage.healix360.net (Global Hospital)
- ramaiah.app.healix360.net / global.app.healix360.net (Platform)
- telephony.engage.healix360.net (Event dispatcher)
- operations.healix360.net (CI/CD dashboard)
- git.healix360.net (Git forge)
---
## 2. Telephony Event Dispatcher
**Status: Live**
Built a NestJS service that routes Ozonetel agent/call events to the correct hospital's sidecar:
- Ozonetel event subscriptions are **account-level** (not per-campaign) — one URL for all agents
- Dispatcher receives all events, looks up `agentId` in Redis, forwards to the correct sidecar
- Sidecars self-register on boot with their agent list; heartbeat every 30s, TTL 90s
- No manual configuration needed when adding new hospitals
---
## 3. Cross-Tenant Security Fix (defaultAgentId)
**Status: Fixed and deployed**
Discovered that 6 sidecar endpoints used a hardcoded `OZONETEL_AGENT_ID` env var as a fallback when `agentId` wasn't provided by the frontend. In a multi-tenant setup, this caused Ramaiah sidecar operations to silently affect Global Hospital's agent.
**Impact:** Agent state changes, call disposition, outbound dialing, performance metrics, and maintenance commands could operate on the wrong hospital's agent with no error or warning.
**Fix:**
- Removed `defaultAgentId` getter and all hardcoded fallbacks (`agent3`, `Test123$`, `521814`)
- All 6 endpoints now require `agentId` from the caller (400 if missing)
- Frontend updated to send `agentId` from `localStorage.helix_agent_config` in all calls
- `OZONETEL_AGENT_ID` removed from env config entirely
---
## 4. Defect Fixes (8 of 17)
| Bug | Title | Status |
|-----|-------|--------|
| #527 | Appointment creation updates existing patient incorrectly | Fixed |
| #529 | Break/Training status doesn't block outbound calls | Fixed |
| #531 | Agent can log out during active call | Fixed |
| #533 | Redundant "Call History" header | Fixed |
| #534 | Redundant "Patients" header | Fixed |
| #536 | My Performance shows wrong agent's data | Fixed |
| #538 | Supervisor dashboard metrics incorrect | Fixed |
| #540 | Ghost calls visible for logged-out agents | Fixed |
| #547 | SLA rules not reflected in Call Desk | Fixed (config seeded) |
**Deferred (by product):** #516 (recordings real-time), #517/#548 (AI transcription), #519 (supervisor call — needs SIP seat), #539 (missed calls real-time), #541 (whisper/barge/listen)
---
## 5. E2E Test Suite (Playwright)
**Status: 40 tests, all passing**
Automated smoke tests covering every page for both hospitals:
- **Login (4):** branding, invalid creds, supervisor login, auth guard
- **Ramaiah CC Agent (10):** call desk, call history, patients, appointments, my performance, sidebar, sign-out
- **Ramaiah Supervisor (12):** dashboard, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
- **Global CC Agent (7):** all pages + sign-out
- **Global Supervisor (5):** all pages
Self-healing: auto-clears agent session locks before login, completes sign-out after tests.
---
## 6. CI/CD Pipeline (Woodpecker + Gitea)
**Status: Operational**
End-to-end CI/CD on EC2:
- **Gitea** mirrors Azure DevOps repos every 15 minutes
- **Woodpecker CI** triggers pipelines on push or manual run
- **Frontend pipeline:** TypeScript typecheck → 40 E2E tests → HTML report published to MinIO → Teams notification
- **Sidecar pipeline:** Jest unit tests → Teams notification
- **Reports:** Playwright HTML reports with screenshots at `operations.healix360.net/reports/{run}/index.html`
- **Teams notifications:** Adaptive Cards to "Deployment updates" channel with pass/fail summary + report link
---
## 7. Documentation
Three docs committed to the repo:
- **architecture.md** — Multi-tenant topology with Mermaid diagram, telephony dispatcher, failure modes
- **developer-operations-runbook.md** — SSH access, accounts, deploy steps, Redis ops, DB access, troubleshooting
- **ci-cd-operations.md** — Gitea, Woodpecker, MinIO, Teams notification setup and troubleshooting
---
## 8. Data Seeding
- **Ramaiah:** 195 real doctors scraped from msrmh.com, clinics, visit slots, campaign data
- **Global:** CC agent accounts (rekha.cc, ganesh.cc), marketing (sanjay), supervisor (dr.ramesh) created with proper roles
- **Rules engine:** 6 priority scoring rules seeded (missed call, follow-up, campaign lead, 2nd/3rd attempt, spam deprioritize)
- **Seed script:** idempotent `mkMember`, cleanup phase before seeding, runs against any workspace via env vars
---
## 9. Other Improvements
- **SIP agent tracing:** Browser console logs `agent=ramaiahadmin ext=524435` on every SIP connect/disconnect/state change for multi-agent debugging
- **ACW 3-layer protection:** beforeunload warning → sendBeacon auto-dispose → server 30s timer
- **Maint endpoints:** `force-ready` and `unlock-agent` now accept `agentId` from body (was hardcoded)
- **Security group automation:** SSH IP auto-updated via AWS CLI when ISP changes
---
## Metrics
| Metric | Value |
|--------|-------|
| Commits (frontend) | 35 |
| Commits (sidecar) | 20 |
| Commits (SDK app) | 2 |
| Bugs fixed | 9 |
| E2E tests | 40 |
| Docker containers | 17 (14 app + 3 CI) |
| DNS records | 6 |
| Uptime | EC2 live since Apr 9 |
---
## Next Week Priorities
1. Merge `feature/omnichannel-widget``master` (frontend)
2. Frontend Docker image (stop rsync, bake into image)
3. Appointment date validation (no past dates, auto-tomorrow after hours)
4. Pre-built CI Docker image (skip `yarn install` on every run)
5. Deferred defects: #516, #539 (real-time updates)

Binary file not shown.

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();
}
});
});

64
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"tailwindcss-react-aria-components": "^2.0.1" "tailwindcss-react-aria-components": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3", "@types/jssip": "^3.5.3",
@@ -1077,6 +1078,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@react-aria/autocomplete": {
"version": "3.0.0-rc.6", "version": "3.0.0-rc.6",
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz", "resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
@@ -5471,6 +5488,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz", "resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",

View File

@@ -43,6 +43,7 @@
"tailwindcss-react-aria-components": "^2.0.1" "tailwindcss-react-aria-components": "^2.0.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3", "@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',
},
},
],
});

View File

@@ -1,23 +1,21 @@
/** /**
* Helix Engage — Platform Data Seeder * Helix Engage — Platform Data Seeder
* Creates 5 patient stories + 5 doctors with fully linked records. * Creates 2 clinics, 5 doctors with multi-clinic visit slots,
* Run: cd helix-engage && npx tsx scripts/seed-data.ts * 3 patient stories with fully linked records (campaigns, leads,
* calls, appointments, follow-ups, lead activities).
* *
* Platform field mapping (SDK name → platform name): * Run: cd helix-engage && npx tsx scripts/seed-data.ts
* Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions, * Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain)
* clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated *
* Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted, * Schema alignment (2026-04-10):
* lastContactedAt→lastContacted, landingPageUrl→landingPage * - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity
* Call: callDirection→direction, durationSeconds→durationSec * - Doctor.portalUserId omitted (workspace member IDs are per-deployment)
* Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room * - Clinic entity added (needed for visit slot FK)
* FollowUp: followUpType→typeCustom, followUpStatus→status
* Patient: address→addressCustom
* Doctor: isActive→active, branch→branchClinic
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed * NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
*/ */
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql'; 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'; const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
let token = ''; let token = '';
@@ -51,28 +49,172 @@ async function mk(entity: string, data: any): Promise<string> {
return d[`create${cap}`].id; 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() { async function main() {
console.log('🌱 Seeding Helix Engage demo data...\n'); console.log('🌱 Seeding Helix Engage demo data...\n');
await auth(); await auth();
console.log('✅ Auth OK\n'); console.log('✅ Auth OK\n');
// Workspace member IDs — switch based on target platform // Clean slate — remove all existing entity data (not users)
const WM = GQL.includes('srv1477139') ? { console.log('🧹 Clearing existing data...');
drSharma: '107efa70-fd32-4819-8936-994197c6ada1', await clearAll();
drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209', console.log('');
drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c',
drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b', await auth();
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',
};
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
// 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'); console.log('👨‍⚕️ Doctors');
const drSharma = await mk('doctor', { const drSharma = await mk('doctor', {
@@ -82,16 +224,15 @@ async function main() {
specialty: 'Interventional Cardiology', specialty: 'Interventional Cardiology',
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC', qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
yearsOfExperience: 18, yearsOfExperience: 18,
visitingHours: 'Mon/Wed/Fri 10:00 AM 1:00 PM',
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' }, consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.sharma@globalhospital.com' }, email: { primaryEmail: 'dr.sharma@globalcare.com' },
registrationNumber: 'KMC-45672', registrationNumber: 'KMC-45672',
active: true, 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', { const drPatel = await mk('doctor', {
name: 'Dr. Meena Patel', name: 'Dr. Meena Patel',
@@ -100,16 +241,15 @@ async function main() {
specialty: 'Reproductive Medicine & IVF', specialty: 'Reproductive Medicine & IVF',
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine', qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
yearsOfExperience: 15, yearsOfExperience: 15,
visitingHours: 'Tue/Thu/Sat 9:00 AM 12:00 PM',
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' }, consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.patel@globalhospital.com' }, email: { primaryEmail: 'dr.patel@globalcare.com' },
registrationNumber: 'KMC-38291', registrationNumber: 'KMC-38291',
active: true, 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', { const drKumar = await mk('doctor', {
name: 'Dr. Rajesh Kumar', name: 'Dr. Rajesh Kumar',
@@ -118,16 +258,15 @@ async function main() {
specialty: 'Joint Replacement & Sports Medicine', specialty: 'Joint Replacement & Sports Medicine',
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty', qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
yearsOfExperience: 12, yearsOfExperience: 12,
visitingHours: 'MonFri 2:00 PM 5:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' }, consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.kumar@globalhospital.com' }, email: { primaryEmail: 'dr.kumar@globalcare.com' },
registrationNumber: 'KMC-51003', registrationNumber: 'KMC-51003',
active: true, 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', { const drReddy = await mk('doctor', {
name: 'Dr. Lakshmi Reddy', name: 'Dr. Lakshmi Reddy',
@@ -136,16 +275,15 @@ async function main() {
specialty: 'Internal Medicine & Preventive Health', specialty: 'Internal Medicine & Preventive Health',
qualifications: 'MBBS, MD (General Medicine)', qualifications: 'MBBS, MD (General Medicine)',
yearsOfExperience: 20, yearsOfExperience: 20,
visitingHours: 'MonSat 9:00 AM 6:00 PM',
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' }, consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.reddy@globalhospital.com' }, email: { primaryEmail: 'dr.reddy@globalcare.com' },
registrationNumber: 'KMC-22145', registrationNumber: 'KMC-22145',
active: true, 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', { const drSingh = await mk('doctor', {
name: 'Dr. Harpreet Singh', name: 'Dr. Harpreet Singh',
@@ -154,16 +292,57 @@ async function main() {
specialty: 'Otorhinolaryngology & Head/Neck Surgery', specialty: 'Otorhinolaryngology & Head/Neck Surgery',
qualifications: 'MBBS, MS (ENT), DNB', qualifications: 'MBBS, MS (ENT), DNB',
yearsOfExperience: 10, yearsOfExperience: 10,
visitingHours: 'Mon/Wed/Fri 11:00 AM 3:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' }, consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' }, consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.singh@globalhospital.com' }, email: { primaryEmail: 'dr.singh@globalcare.com' },
registrationNumber: 'KMC-60782', registrationNumber: 'KMC-60782',
active: true, 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(); await auth();
@@ -406,9 +585,10 @@ async function main() {
console.log(' Vijay — appointment reminder (tomorrow 9am)\n'); console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
console.log('🎉 Seed complete!'); 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(' 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); }); main().catch(e => { console.error('💥', e.message); process.exit(1); });

View File

@@ -5,11 +5,13 @@
* Prerequisites: doctors already seeded via seed-data.ts * Prerequisites: doctors already seeded via seed-data.ts
* *
* Platform field mapping (SDK name → platform name): * Platform field mapping (SDK name → platform name):
* Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours, * Clinic: address→addressCustom,
* operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours, * per-day booleans openMonday..openSunday + opensAt/closesAt (HH:MM),
* clinicStatus→status, onlineBookingEnabled→onlineBooking, * clinicStatus→status, onlineBookingEnabled→onlineBooking,
* arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash, * arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash,
* paymentCard→acceptsCard, paymentUpi→acceptsUpi * paymentCard→acceptsCard, paymentUpi→acceptsUpi.
* requiredDocuments is a RELATION (ClinicRequiredDocument); seed rows
* separately — not a string on the Clinic itself.
* HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active * HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active
* InsurancePartner: planTypes→planTypesAccepted * InsurancePartner: planTypes→planTypesAccepted
*/ */
@@ -68,15 +70,16 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'koramangala@globalhospital.com' }, email: { primaryEmail: 'koramangala@globalhospital.com' },
weekdayHours: '8:00 AM 8:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '8:00 AM 8:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
sundayHours: '9:00 AM 2:00 PM', opensAt: '08:00',
closesAt: '20:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records', // requiredDocuments is a relation (ClinicRequiredDocument) — seed separately
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',
@@ -95,15 +98,15 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'whitefield@globalhospital.com' }, email: { primaryEmail: 'whitefield@globalhospital.com' },
weekdayHours: '8:00 AM 8:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '8:00 AM 8:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
sundayHours: 'Closed', opensAt: '08:00',
closesAt: '20:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records',
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',
@@ -122,15 +125,15 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'indiranagar@globalhospital.com' }, email: { primaryEmail: 'indiranagar@globalhospital.com' },
weekdayHours: '9:00 AM 7:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '9:00 AM 7:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
sundayHours: '10:00 AM 1:00 PM', opensAt: '09:00',
closesAt: '19:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records',
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',

View File

@@ -0,0 +1,114 @@
/**
* Seed DoctorVisitSlots for all Ramaiah doctors.
* Assigns default visiting hours based on department patterns.
* Run after seed-ramaiah.ts has populated doctors + clinic.
*
* 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-slots.ts
*/
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';
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) 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;
}
// Default schedule patterns by department type
const schedulePatterns: Record<string, { days: string[]; start: string; end: string }> = {
// Surgical departments: morning OPD
surgery: { days: ['MONDAY', 'WEDNESDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '13:00' },
// Medical departments: afternoon OPD
medicine: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '14:00', end: '17:00' },
// High-traffic: full day Mon-Sat
fullDay: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '17:00' },
// Emergency/Critical: all week
allWeek: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'], start: '08:00', end: '20:00' },
// Specialists: limited days
specialist: { days: ['TUESDAY', 'THURSDAY', 'SATURDAY'], start: '10:00', end: '14:00' },
};
function getPattern(department: string): { days: string[]; start: string; end: string } {
const d = department.toLowerCase();
if (d.includes('emergency') || d.includes('critical care')) return schedulePatterns.allWeek;
if (d.includes('general medicine') || d.includes('paediatrics') || d.includes('obstetrics')) return schedulePatterns.fullDay;
if (d.includes('surgery') || d.includes('ortho') || d.includes('neuro')) return schedulePatterns.surgery;
if (d.includes('cardiology') || d.includes('nephrology') || d.includes('oncology')) return schedulePatterns.medicine;
if (d.includes('dermatology') || d.includes('psychiatry') || d.includes('rheumatology') || d.includes('endocrinology')) return schedulePatterns.specialist;
// Default: Mon-Fri mornings
return { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '09:00', end: '13:00' };
}
async function main() {
console.log('🕐 Seeding visit slots for Ramaiah doctors...\n');
await auth();
console.log('✅ Auth OK\n');
// Fetch all doctors
const docData = await gql(`{ doctors(first: 500) { edges { node { id name department } } } }`);
const doctors = docData.doctors.edges.map((e: any) => e.node);
console.log(`📋 Found ${doctors.length} doctors\n`);
// Fetch clinic
const clinicData = await gql(`{ clinics(first: 1) { edges { node { id clinicName } } } }`);
const clinicId = clinicData.clinics.edges[0]?.node.id;
const clinicName = clinicData.clinics.edges[0]?.node.clinicName ?? 'Clinic';
if (!clinicId) { console.error('No clinic found!'); process.exit(1); }
console.log(`🏥 Clinic: ${clinicName} (${clinicId})\n`);
let created = 0;
let failed = 0;
for (let i = 0; i < doctors.length; i++) {
if (i > 0 && i % 40 === 0) {
await auth();
console.log(` (re-authed at ${i})`);
}
const doc = doctors[i];
const pattern = getPattern(doc.department ?? '');
for (const day of pattern.days) {
try {
await gql(
`mutation($data: DoctorVisitSlotCreateInput!) { createDoctorVisitSlot(data: $data) { id } }`,
{
data: {
name: `${doc.name}${day} ${pattern.start}${pattern.end}`,
doctorId: doc.id,
clinicId,
dayOfWeek: day,
startTime: pattern.start,
endTime: pattern.end,
},
},
);
created++;
} catch (err: any) {
failed++;
if (failed <= 5) console.error(`${doc.name} ${day}: ${err.message?.slice(0, 60)}`);
}
}
if ((i + 1) % 30 === 0) console.log(` ${i + 1}/${doctors.length} doctors processed (${created} slots)...`);
}
console.log(`\n✅ ${created} visit slots created, ${failed} failed`);
console.log(` ${doctors.length} doctors × avg ${Math.round(created / doctors.length)} days each`);
}
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

@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
onApply?: () => void; onApply?: () => void;
/** The function to call when the cancel button is clicked. */ /** The function to call when the cancel button is clicked. */
onCancel?: () => void; onCancel?: () => void;
/** Override popover placement — use "top start" in narrow panels
* where "bottom start" would overflow the viewport. */
popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end';
} }
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => { export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => {
const formatter = useDateFormatter({ const formatter = useDateFormatter({
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
</AriaGroup> </AriaGroup>
<AriaPopover <AriaPopover
offset={8} offset={8}
placement="bottom start" placement={popoverPlacement ?? "bottom start"}
shouldFlip shouldFlip
className={({ isEntering, isExiting }) => className={({ isEntering, isExiting }) =>
cx( cx(

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,10 +1,11 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion, faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
@@ -12,12 +13,16 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
import { setOutboundPending } from '@/state/sip-manager'; import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { DispositionModal } from './disposition-modal'; import { DispositionModal } from './disposition-modal';
import type { CallAction } from './disposition-modal';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog'; import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form'; import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format'; import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities'; import type { Lead, CallDisposition } from '@/types/entities';
@@ -37,32 +42,134 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
const networkQuality = useNetworkStatus();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const [appointmentOpen, setAppointmentOpen] = useState(false); const [appointmentOpen, setAppointmentOpen] = useState(false);
// Which existing appointment is being edited (null = creating a new one).
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
// Clicking Edit on a pill sets this; clicking + New clears it.
const [editingApptId, setEditingApptId] = useState<string | null>(null);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false);
const [dispositionOpen, setDispositionOpen] = useState(false); const [dispositionOpen, setDispositionOpen] = useState(false);
const [callerDisconnected, setCallerDisconnected] = useState(false); const [callerDisconnected, setCallerDisconnected] = useState(false);
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null); // Actions actually recorded during this call. Drives the disposition
// modal's priority-lock: if the agent booked an appointment and logged
// an enquiry, both badges render and the primary disposition is
// locked to APPOINTMENT_BOOKED.
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
const addActions = (...newActions: CallAction[]) => {
setActionsTaken((prev) => {
const next = new Set(prev);
for (const a of newActions) next.add(a);
return Array.from(next);
});
};
// Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones.
const { appointments, refresh } = useData();
const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) return [];
const now = Date.now();
return appointments
.filter((a) =>
a.patientId === patientId
&& a.appointmentStatus !== 'CANCELLED'
&& a.appointmentStatus !== 'NO_SHOW'
&& a.appointmentStatus !== 'COMPLETED'
// Only future appointments make sense as reschedule targets.
// Past ones can't be edited — they already happened.
&& a.scheduledAt
&& new Date(a.scheduledAt).getTime() >= now,
)
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
}, [appointments, lead]);
const editingAppt = useMemo(
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
[leadAppointments, editingApptId],
);
// Pending pill click awaiting the reschedule-confirm modal. When the
// agent clicks a pill, we store the appointment id here + open the modal.
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const isOutbound = callDirectionRef.current === 'OUTBOUND';
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
// confirmedAnswered — latched state (did a real conversation happen?)
// Inbound: set true on active (immediate). Outbound: set true after
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
// the acw→ended timing gap. Used for disposition routing AND outbound
// button gating.
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
const unansweredDisposeFired = useRef(false);
useEffect(() => {
if (!isOutbound && callState === 'active') {
setConfirmedAnswered(true);
}
}, [callState, isOutbound]);
useEffect(() => {
if (isOutbound && customerAnswered && !confirmedAnswered) {
const timer = setTimeout(() => {
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
setConfirmedAnswered(true);
}, 3000);
return () => clearTimeout(timer);
}
}, [customerAnswered, isOutbound, confirmedAnswered]);
// Button gating: inbound uses live signal, outbound uses debounced latch
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
// ── DEBUG: trace every state change ──
useEffect(() => {
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
useEffect(() => { useEffect(() => {
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); 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 }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Detect caller disconnect: call was active and ended without agent pressing End // 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(() => { useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { 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 ended without agent pressing End.
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
useEffect(() => {
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
if (confirmedAnswered) {
setCallerDisconnected(true); setCallerDisconnected(true);
setDispositionOpen(true); setDispositionOpen(true);
} }
}, [callState, dispositionOpen]); }, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -78,13 +185,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Submit disposition to sidecar // Submit disposition to sidecar
if (callUcid) { if (callUcid) {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const disposePayload = { const disposePayload = {
ucid: callUcid, ucid: callUcid,
disposition, disposition,
agentId: agentCfg.ozonetelAgentId,
callerPhone, callerPhone,
direction: callDirectionRef.current, direction: callDirectionRef.current,
durationSec: callDuration, durationSec: callDuration,
leadId: lead?.id ?? null, leadId: lead?.id ?? null,
leadName: fullName || null,
notes, notes,
missedCallId: missedCallId ?? undefined, missedCallId: missedCallId ?? undefined,
}; };
@@ -96,38 +206,41 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
console.warn('[DISPOSE] No callUcid — skipping disposition'); console.warn('[DISPOSE] No callUcid — skipping disposition');
} }
// Side effects // Follow-ups are created by the enquiry form (where the agent picks
if (disposition === 'FOLLOW_UP_SCHEDULED') { // the date + context). No second creation here — that was causing
try { // duplicate entries on every FOLLOW_UP_SCHEDULED call.
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
data: { // Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
name: `Follow-up — ${fullName || phoneDisplay}`, localStorage.removeItem('helix_active_ucid');
typeCustom: 'CALLBACK',
status: 'PENDING',
assignedAgent: null,
priority: 'NORMAL',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
}, { silent: true });
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
} catch {
notify.info('Follow-up', 'Could not auto-create follow-up');
}
}
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
handleReset(); handleReset();
}; };
const handleAppointmentSaved = () => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
setSuggestedDisposition('APPOINTMENT_BOOKED'); refresh();
notify.success('Appointment Booked', 'Payment link will be sent to the patient'); // Invalidate sidecar's caller context cache so AI gets fresh appointment data
if (lead?.id) {
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
}
if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE');
notify.success('Appointment Rescheduled');
} else if (outcome === 'CANCELLED') {
addActions('CANCEL');
notify.success('Appointment Cancelled');
} else {
addActions('APPOINTMENT');
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
}
}; };
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setConfirmedAnswered(false);
setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
setCallUcid(null); setCallUcid(null);
@@ -135,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.(); onCallComplete?.();
}; };
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
useEffect(() => {
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
unansweredDisposeFired.current = true;
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
apiClient.post('/api/ozonetel/dispose', {
ucid: callUcid,
disposition: 'NO_ANSWER',
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: 'OUTBOUND',
durationSec: 0,
leadId: lead?.id ?? null,
leadName: fullName || null,
notes: 'Auto-disposed — customer did not answer',
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
}
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
// Outbound ringing // Outbound ringing
if (callState === 'ringing-out') { if (callState === 'ringing-out') {
return ( return (
@@ -152,11 +285,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>} {fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> {/* Cancel button removed per product — risk: agent can't abort
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}> a misdialled outbound call before the customer answers.
Cancel <Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
</Button>
</div>
</div> </div>
); );
} }
@@ -180,18 +311,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Button size="sm" color="primary" onClick={answer}>Answer</Button> <Button size="sm" color="primary" onClick={answer}>Answer</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button> {/* Decline hidden per product — reject returns call to Ozonetel queue */}
</div> </div>
</div> </div>
); );
} }
// Unanswered call (ringing → ended without ever reaching active) if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
return ( return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center"> <div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
<p className="text-sm font-semibold text-primary">Missed Call</p> <p className="text-sm font-semibold text-primary">{fullName || 'Missed Call'}</p>
<p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p> <p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}> <Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist Back to Worklist
@@ -202,10 +333,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true;
return ( return (
<> <>
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}> <div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
{/* Network loss alert — prominent banner during active call */}
{networkQuality !== 'good' && (
<div className={cx(
'shrink-0 px-4 py-2 text-xs font-medium text-center',
networkQuality === 'offline'
? 'bg-error-solid text-white'
: 'bg-warning-secondary text-warning-primary',
)}>
{networkQuality === 'offline'
? 'Network connection lost — call may have dropped'
: 'Network unstable — call quality may be affected'}
</div>
)}
{/* Pinned: caller info + controls */} {/* Pinned: caller info + controls */}
<div className="shrink-0 p-4"> <div className="shrink-0 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -218,7 +362,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>} {fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge> <div className="flex items-center gap-2">
{supervisorPresence === 'whisper' && (
<Badge size="sm" color="blue" type="pill-color">Supervisor coaching</Badge>
)}
{supervisorPresence === 'barge' && (
<Badge size="sm" color="brand" type="pill-color">Supervisor on call</Badge>
)}
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
</div> </div>
{/* Call controls */} {/* Call controls */}
@@ -262,12 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button> isDisabled={!buttonsEnabled}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!buttonsEnabled}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button> onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'} <Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
isDisabled={!buttonsEnabled}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button> onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto" <Button size="sm" color="primary-destructive" className="ml-auto"
@@ -287,44 +444,170 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onClose={() => setTransferOpen(false)} onClose={() => setTransferOpen(false)}
onTransferred={() => { onTransferred={() => {
setTransferOpen(false); setTransferOpen(false);
setSuggestedDisposition('FOLLOW_UP_SCHEDULED'); // A transfer implies the original agent handed the call
// off — treat that as a follow-up action so the
// disposition pre-locks to FOLLOW_UP_SCHEDULED.
addActions('FOLLOWUP');
setDispositionOpen(true); setDispositionOpen(true);
}} }}
/> />
)} )}
{appointmentOpen && leadAppointments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<button
type="button"
onClick={() => setEditingApptId(null)}
className={cx(
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
!editingApptId
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
)}
>
<FontAwesomeIcon icon={faPlus} className="size-3" />
New
</button>
{leadAppointments.map((appt) => (
<div
key={appt.id}
className={cx(
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
editingApptId === appt.id
? 'border-brand bg-brand-primary'
: 'border-secondary bg-primary',
)}
>
<div className="flex flex-col">
<span className="font-semibold text-primary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
</span>
<span className="text-[11px] text-tertiary">
{appt.doctorName ?? 'Doctor'}
</span>
</div>
<button
type="button"
onClick={() => setPendingApptId(appt.id)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
Edit
</button>
</div>
))}
</div>
)}
{/* Key forces a full remount when switching between
pills (or between edit/view modes) so the form's
internal state re-initializes from the new
existingAppointment prop instead of staying
stuck on the first-mounted values. */}
<AppointmentForm <AppointmentForm
key={`${editingApptId ?? 'new'}-${apptMode}`}
isOpen={appointmentOpen} isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen} onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) { setEditingApptId(null); setApptMode('edit'); }
}}
callerNumber={callerPhone} callerNumber={callerPhone}
leadName={fullName || null} leadName={fullName || null}
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved} readOnly={apptMode === 'view'}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt ?? '',
doctorName: editingAppt.doctorName ?? '',
doctorId: editingAppt.doctorId ?? undefined,
department: editingAppt.department ?? '',
clinicId: editingAppt.clinicId ?? undefined,
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
} : null}
onSaved={(outcome) => {
setEditingApptId(null);
setApptMode('edit');
handleAppointmentSaved(outcome);
}}
/> />
<EnquiryForm <EnquiryForm
isOpen={enquiryOpen} isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen} onOpenChange={setEnquiryOpen}
callerPhone={callerPhone} callerPhone={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
agentName={user.name} agentName={user.name}
onSaved={() => { onSaved={(actions) => {
setEnquiryOpen(false); setEnquiryOpen(false);
setSuggestedDisposition('INFO_PROVIDED'); addActions(...actions);
notify.success('Enquiry Logged');
}} }}
/> />
</div> </div>
)} )}
</div> </div>
{/* Reschedule confirm modal — fires when the agent clicks Edit
on an upcoming-appointment pill. Yes → open the form in
edit mode (fields editable, Save button). No → open in
view-only mode (fields disabled, Close button). */}
<ModalOverlay
isOpen={pendingApptId !== null}
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
isDismissable
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
color="secondary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('view');
setPendingApptId(null);
}
}}
>
No, just view
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('edit');
setPendingApptId(null);
}
}}
>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
{/* Disposition Modal — the ONLY path to end a call */} {/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal <DispositionModal
isOpen={dispositionOpen} isOpen={dispositionOpen}
callerName={fullName || phoneDisplay} callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected} callerDisconnected={callerDisconnected}
defaultDisposition={suggestedDisposition} callAnswered={confirmedAnswered}
actionsTaken={actionsTaken}
onSubmit={handleDisposition} onSubmit={handleDisposition}
onDismiss={() => { onDismiss={() => {
// Agent wants to continue the call — close modal, call stays active // Agent wants to continue the call — close modal, call stays active

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons'; import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
import type { OzonetelState } from '@/hooks/use-agent-state'; import type { OzonetelState } from '@/hooks/use-agent-state';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -33,7 +33,7 @@ type AgentStatusToggleProps = {
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => { export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null; const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
const ozonetelState = useAgentState(agentId); const { state: ozonetelState } = useAgentState(agentId);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [changing, setChanging] = useState(false); const [changing, setChanging] = useState(false);
@@ -46,12 +46,21 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
try { try {
if (newStatus === 'ready') { if (newStatus === 'ready') {
console.log('[AGENT-STATE] Changing to 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)); console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else { } else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training'; const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
// transit through Ready. Insert a Ready hop whenever we're
// moving between two paused sub-states.
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
if (isPauseToPause) {
console.log(`[AGENT-STATE] ${ozonetelState}${newStatus}: sending Ready first, then Pause(${pauseReason})`);
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
await new Promise(resolve => setTimeout(resolve, 400));
}
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`); 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)); console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
} }
// Don't setStatus — SSE will push the real state // Don't setStatus — SSE will push the real state
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
disabled={changing || !canToggle} disabled={changing || !canToggle}
className={cx( className={cx(
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear', 'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default', canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
changing && 'opacity-50',
)} )}
> >
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} /> {changing ? (
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span> <FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />} ) : (
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
)}
<span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
{changing ? 'Changing…' : current.label}
</span>
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
</button> </button>
{menuOpen && ( {menuOpen && (

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useThemeTokens } from '@/providers/theme-token-provider'; import { useThemeTokens } from '@/providers/theme-token-provider';
import { useChat } from '@ai-sdk/react'; import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
import { AiSuggestions, type Suggestion } from './ai-suggestions';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
@@ -16,28 +18,62 @@ type CallerContext = {
interface AiChatPanelProps { interface AiChatPanelProps {
callerContext?: CallerContext; callerContext?: CallerContext;
callerSummary?: CallerSummary | null;
onChatStart?: () => void; onChatStart?: () => void;
} }
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { const SUPERVISOR_QUICK_ACTIONS = [
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
];
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => {
const trimmed = content.trim();
try {
const parsed = JSON.parse(trimmed);
if (parsed.message) {
return {
message: parsed.message,
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
};
}
} catch {}
return { message: content, suggestions: [] };
};
export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens(); const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions; const isSupervisor = callerContext?.type === 'supervisor';
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatStartedRef = useRef(false); const chatStartedRef = useRef(false);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({ const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
api: `${API_URL}/api/ai/stream`, api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text', streamProtocol: 'text',
headers: { headers: { 'Authorization': `Bearer ${token}` },
'Authorization': `Bearer ${token}`, body: { context: callerContext },
},
body: {
context: callerContext,
},
}); });
useEffect(() => {
if (isLoading) return;
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAssistant) {
const parsed = parseAiResponse(lastAssistant.content);
if (parsed.suggestions.length > 0) {
setSuggestions(parsed.suggestions);
}
}
}, [messages, isLoading]);
useEffect(() => { useEffect(() => {
const el = messagesEndRef.current; const el = messagesEndRef.current;
if (el?.parentElement) { if (el?.parentElement) {
@@ -49,19 +85,65 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
} }
}, [messages, onChatStart]); }, [messages, onChatStart]);
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
if (!leadId) {
if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null;
setMessages([]);
setSuggestions([]);
chatStartedRef.current = false;
}
return;
}
if (autoFiredForLeadRef.current === leadId) return;
autoFiredForLeadRef.current = leadId;
setMessages([]);
setSuggestions([]);
chatStartedRef.current = false;
const name = callerContext?.leadName ?? 'this caller';
append({
role: 'user',
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
});
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
const handleQuickAction = (prompt: string) => { const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt }); append({ role: 'user', content: prompt });
}; };
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
append({
role: 'user',
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
});
}, [append]);
// Filter out the currently-streaming assistant message (shows raw JSON).
// Only display completed assistant messages with parsed content.
const displayMessages = messages
.filter((msg, i) => {
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
return true;
})
.map(msg => {
if (msg.role === 'assistant') {
const parsed = parseAiResponse(msg.content);
return { ...msg, content: parsed.message };
}
return msg;
});
return ( return (
<div className="flex h-full flex-col p-3"> <div className="flex h-full flex-col gap-2 p-3">
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
<div className="flex-1 space-y-3 overflow-y-auto min-h-0"> <div className="flex-1 space-y-3 overflow-y-auto min-h-0">
{messages.length === 0 && ( {displayMessages.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-xs text-tertiary"> <p className="text-xs text-tertiary">{introText}</p>
Ask me about doctors, clinics, packages, or patient info.
</p>
<div className="mt-3 flex flex-wrap justify-center gap-1.5"> <div className="mt-3 flex flex-wrap justify-center gap-1.5">
{quickActions.map((action) => ( {quickActions.map((action) => (
<button <button
@@ -77,18 +159,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
</div> </div>
)} )}
{messages.map((msg) => ( {displayMessages.map((msg) => (
<div <div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
key={msg.id} <div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
> }`}>
<div
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
msg.role === 'user'
? 'bg-brand-solid text-white'
: 'bg-secondary text-primary'
}`}
>
{msg.role === 'assistant' && ( {msg.role === 'assistant' && (
<div className="mb-1 flex items-center gap-1"> <div className="mb-1 flex items-center gap-1">
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
@@ -115,7 +190,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0"> {!isSupervisor && suggestions.length > 0 && (
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
)}
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100"> <div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" /> <FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
<input <input
@@ -138,20 +217,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
</div> </div>
); );
}; };
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
const parseLine = (text: string): ReactNode[] => { const parseLine = (text: string): ReactNode[] => {
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
const boldPattern = /\*\*(.+?)\*\*/g; const boldPattern = /\*\*(.+?)\*\*/g;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = boldPattern.exec(text)) !== null) { while ((match = boldPattern.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>); parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
lastIndex = boldPattern.lastIndex; lastIndex = boldPattern.lastIndex;
} }
if (lastIndex < text.length) parts.push(text.slice(lastIndex)); if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? parts : [text]; return parts.length > 0 ? parts : [text];
}; };
@@ -159,7 +235,6 @@ const parseLine = (text: string): ReactNode[] => {
const MessageContent = ({ content }: { content: string }) => { const MessageContent = ({ content }: { content: string }) => {
if (!content) return null; if (!content) return null;
const lines = content.split('\n'); const lines = content.split('\n');
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{lines.map((line, i) => { {lines.map((line, i) => {

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTag, faArrowUp, faRotate, faClipboardCheck, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
export type Suggestion = {
id: string;
type: 'upsell' | 'crosssell' | 'retention' | 'operational';
title: string;
script: string;
priority: 'high' | 'medium' | 'low';
};
interface AiSuggestionsProps {
suggestions: Suggestion[];
onTellMeMore: (suggestion: Suggestion) => void;
}
const TYPE_ICONS = {
upsell: faArrowUp,
crosssell: faTag,
retention: faRotate,
operational: faClipboardCheck,
};
const PRIORITY_COLORS = {
high: 'bg-error-solid',
medium: 'bg-warning-solid',
low: 'bg-success-solid',
};
export const AiSuggestions = ({ suggestions, onTellMeMore }: AiSuggestionsProps) => {
const [collapsed, setCollapsed] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
if (suggestions.length === 0) return null;
return (
<div className="rounded-xl border border-secondary bg-primary">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center justify-between px-3 py-2 text-left"
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-tertiary">
Suggestions ({suggestions.length})
</span>
<FontAwesomeIcon
icon={collapsed ? faChevronDown : faChevronUp}
className="size-2.5 text-fg-quaternary"
/>
</button>
{!collapsed && (
<div className="space-y-1 px-2 pb-2">
{suggestions.map((s) => {
const isExpanded = expandedId === s.id;
return (
<div
key={s.id}
className={cx(
'rounded-lg border transition duration-100 ease-linear',
isExpanded ? 'border-brand bg-brand-primary' : 'border-secondary hover:border-tertiary',
)}
>
<button
onClick={() => setExpandedId(isExpanded ? null : s.id)}
className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
>
<FontAwesomeIcon
icon={TYPE_ICONS[s.type]}
className="size-3 text-fg-brand-secondary shrink-0"
/>
<span className="flex-1 text-xs font-medium text-primary truncate">
{s.title}
</span>
<span className={cx('size-1.5 rounded-full shrink-0', PRIORITY_COLORS[s.priority])} />
</button>
{isExpanded && (
<div className="px-2.5 pb-2.5">
<p className="text-xs text-secondary leading-relaxed mb-2">
{s.script}
</p>
<button
onClick={(e) => {
e.stopPropagation();
onTellMeMore(s);
}}
className="text-[10px] font-medium text-brand-secondary hover:text-brand-primary transition"
>
Tell me more &rarr;
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,88 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faCalendarCheck, faPhone } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
export type CallerSummary = {
name: string;
phone: string;
isNew: boolean;
aiSummary?: string | null;
leadSource?: string | null;
utmCampaign?: string | null;
nextAppointment?: { scheduledAt: string; doctorName: string; department: string } | null;
lastAppointment?: { scheduledAt: string; status: string; department: string } | null;
};
interface AiSummaryCardProps {
caller: CallerSummary | null;
}
const formatDate = (dateStr: string): string => {
const d = new Date(dateStr);
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
};
export const AiSummaryCard = ({ caller }: AiSummaryCardProps) => {
if (!caller) {
return (
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
<p className="text-xs text-quaternary text-center">Select a patient or receive a call</p>
</div>
);
}
return (
<div className="rounded-xl border border-secondary bg-secondary_alt p-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-primary">
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold text-primary truncate">{caller.name || caller.phone}</span>
<Badge size="sm" color={caller.isNew ? 'brand' : 'success'} type="pill-color">
{caller.isNew ? 'New' : 'Returning'}
</Badge>
</div>
{caller.name && (
<span className="text-[10px] text-tertiary">{caller.phone}</span>
)}
</div>
</div>
{caller.aiSummary && (
<p className="text-xs text-secondary leading-relaxed line-clamp-2">{caller.aiSummary}</p>
)}
{(caller.leadSource || caller.utmCampaign) && (
<div className="flex flex-wrap gap-1">
{caller.leadSource && (
<Badge size="sm" color="gray" type="pill-color">{caller.leadSource}</Badge>
)}
{caller.utmCampaign && (
<Badge size="sm" color="purple" type="pill-color">{caller.utmCampaign}</Badge>
)}
</div>
)}
<div className="flex gap-2">
{caller.nextAppointment && (
<div className="flex items-center gap-1.5 rounded-lg bg-success-primary px-2 py-1">
<FontAwesomeIcon icon={faCalendarCheck} className="size-2.5 text-fg-success-primary" />
<span className="text-[10px] font-medium text-success-primary">
{formatDate(caller.nextAppointment.scheduledAt)} &middot; {caller.nextAppointment.doctorName}
</span>
</div>
)}
{caller.lastAppointment && (
<div className="flex items-center gap-1.5 rounded-lg bg-secondary px-2 py-1">
<FontAwesomeIcon icon={faPhone} className="size-2.5 text-fg-quaternary" />
<span className="text-[10px] text-tertiary">
Last: {formatDate(caller.lastAppointment.scheduledAt)} &middot; {caller.lastAppointment.status}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,13 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { DatePicker } from '@/components/application/date-picker/date-picker'; import { DatePicker } from '@/components/application/date-picker/date-picker';
import { parseDate } from '@internationalized/date'; import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
type ExistingAppointment = { type ExistingAppointment = {
id: string; id: string;
@@ -15,6 +18,7 @@ type ExistingAppointment = {
doctorName: string; doctorName: string;
doctorId?: string; doctorId?: string;
department: string; department: string;
clinicId?: string;
reasonForVisit?: string; reasonForVisit?: string;
status: string; status: string;
}; };
@@ -26,17 +30,22 @@ type AppointmentFormProps = {
leadName?: string | null; leadName?: string | null;
leadId?: string | null; leadId?: string | null;
patientId?: string | null; patientId?: string | null;
onSaved?: () => void; // Called after a successful save. Passes back what actually happened so
// the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs
// CANCELLED each map to distinct disposition outcomes).
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
existingAppointment?: ExistingAppointment | null; existingAppointment?: ExistingAppointment | null;
// When true, the form shows the existing appointment's data in a
// disabled state — no input editing, no Save/Cancel. Only a Close
// button. Used by the reschedule-confirm flow when the agent picks
// "No, just view" on an upcoming-appointment pill.
readOnly?: boolean;
}; };
type DoctorRecord = { id: string; name: string; department: string; clinic: string }; type DoctorRecord = { id: string; name: string; department: string; clinic: string };
const clinicItems = [ // Clinics are fetched dynamically from the platform — no hardcoded list.
{ id: 'koramangala', label: 'Global Hospital - Koramangala' }, // If the workspace has no clinics configured, the dropdown shows empty.
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
];
const genderItems = [ const genderItems = [
{ id: 'male', label: 'Male' }, { id: 'male', label: 'Male' },
@@ -44,22 +53,8 @@ const genderItems = [
{ id: 'other', label: 'Other' }, { id: 'other', label: 'Other' },
]; ];
const timeSlotItems = [ // Time slots are fetched from /api/masterdata/slots based on
{ id: '09:00', label: '9:00 AM' }, // doctor + date. No hardcoded times.
{ 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());
export const AppointmentForm = ({ export const AppointmentForm = ({
isOpen, isOpen,
@@ -70,18 +65,36 @@ export const AppointmentForm = ({
patientId, patientId,
onSaved, onSaved,
existingAppointment, existingAppointment,
readOnly = false,
}: AppointmentFormProps) => { }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment; const isEditMode = !!existingAppointment;
// Doctor data from platform // Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]); 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 // Form state — initialized from existing appointment in edit mode
const [patientName, setPatientName] = useState(leadName ?? ''); 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 [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState(''); const [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null); const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null); // Preload clinic from the existing appointment when editing — so the
// select lands on the right branch instead of being empty and forcing
// the agent to re-pick. Only historical rows that predate clinicId
// persistence will fall through to the auto-select-from-slot logic.
const [clinic, setClinic] = useState<string | null>(existingAppointment?.clinicId ?? null);
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null); const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null); const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [date, setDate] = useState(() => { const [date, setDate] = useState(() => {
@@ -98,6 +111,47 @@ export const AppointmentForm = ({
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? ''); const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [source, setSource] = useState('Inbound Call'); const [source, setSource] = useState('Inbound Call');
const [agentNotes, setAgentNotes] = useState(''); 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 => {
// Filter by selected clinic — doctor may visit multiple branches
const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
let items = filtered.map(s => ({ id: s.time, label: s.label }));
// In edit mode, the saved timeSlot may have been filtered out
// (past-slot filter, schedule change, clinic mismatch). Inject
// it as a synthetic option so the dropdown still shows the
// existing value — otherwise the agent sees a cleared field
// and assumes the save-time was lost.
if (timeSlot && !items.some(i => i.id === timeSlot)) {
items = [{ id: timeSlot, label: `${timeSlot} (current)` }, ...items];
}
setTimeSlotItems(items);
// Auto-select clinic from the slot's clinic only if no clinic chosen
if (filtered.length === 0 && slots.length > 0 && !clinic) {
setClinic(slots[0].clinicId);
const autoItems = slots.filter(s => s.clinicId === slots[0].clinicId).map(s => ({ id: s.time, label: s.label }));
if (timeSlot && !autoItems.some(i => i.id === timeSlot)) {
autoItems.unshift({ id: timeSlot, label: `${timeSlot} (current)` });
}
setTimeSlotItems(autoItems);
}
}).catch(() => setTimeSlotItems([]));
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
// deliberately excluded. Including clinic causes a loop: the effect calls
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
// timeSlot is only needed for the synthetic "current" option injection which
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
}, [doctor, date]);
// Availability state // Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]); const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -106,24 +160,29 @@ export const AppointmentForm = ({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>( apiClient.get<Array<{ id: string; name: string; phone: string; address: string }>>('/api/masterdata/clinics')
`{ doctors(first: 50) { edges { node { .then(clinics => {
id name fullName { firstName lastName } department clinic { id name clinicName } setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' })));
} } } }`, }).catch(() => {});
).then(data => { }, [isOpen]);
const docs = data.doctors.edges.map(e => ({
id: e.node.id, useEffect(() => {
name: e.node.fullName if (!isOpen) return;
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() apiClient.get<Array<{ id: string; name: string; department: string; qualifications: string }>>('/api/masterdata/doctors')
: e.node.name, .then(docs => {
department: e.node.department ?? '', setDoctors(docs.map(d => ({
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '', id: d.id,
})); name: d.name,
setDoctors(docs); department: d.department,
}).catch(() => {}); clinic: '', // clinic assignment via visit slots, not on doctor directly
})));
}).catch(() => {});
}, [isOpen]); }, [isOpen]);
// Fetch booked slots when doctor + date selected // Fetch booked slots when doctor + date selected
@@ -172,20 +231,41 @@ export const AppointmentForm = ({
setTimeSlot(null); setTimeSlot(null);
}, [doctor, date]); }, [doctor, date]);
// Derive department and doctor lists from fetched data // Departments from master data (or fallback to deriving from doctors)
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))] const [departmentItems, setDepartmentItems] = useState<Array<{ id: string; label: string }>>([]);
.map(dept => ({ id: dept, label: formatDeptLabel(dept) })); 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 const filteredDoctors = department
? doctors.filter(d => d.department === department) ? doctors.filter(d => d.department === department)
: doctors; : doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name })); // Always include the currently-selected doctor even if the department
// filter would exclude them. Needed for edit mode: the saved
// Appointment.department may be stored as a display string ("ENT") or
// a legacy value that doesn't match the doctor's current department
// enum — without this, the Select renders blank.
const doctorSelectItems = useMemo(() => {
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
if (doctor && !items.some(i => i.id === doctor)) {
const selected = doctors.find(d => d.id === doctor);
if (selected) items.unshift({ id: selected.id, label: selected.name });
}
return items;
}, [filteredDoctors, doctors, doctor]);
const timeSlotSelectItems = timeSlotItems.map(slot => ({ const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
...slot, ...slot,
isDisabled: bookedSlots.includes(slot.id), isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label, label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
})); })), [timeSlotItems, bookedSlots]);
const handleSave = async () => { const handleSave = async () => {
if (!date || !timeSlot || !doctor || !department) { if (!date || !timeSlot || !doctor || !department) {
@@ -207,7 +287,9 @@ export const AppointmentForm = ({
const selectedDoctor = doctors.find(d => d.id === doctor); const selectedDoctor = doctors.find(d => d.id === doctor);
if (isEditMode && existingAppointment) { if (isEditMode && existingAppointment) {
// Update existing appointment // Update existing appointment. Flip status to RESCHEDULED so
// the Appointments > Rescheduled tab reflects it and the
// patient timeline records the reschedule event.
await apiClient.graphql( await apiClient.graphql(
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) { `mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id } updateAppointment(id: $id, data: $data) { id }
@@ -220,47 +302,114 @@ export const AppointmentForm = ({
department: selectedDoctor?.department ?? '', department: selectedDoctor?.department ?? '',
doctorId: doctor, doctorId: doctor,
reasonForVisit: chiefComplaint || null, reasonForVisit: chiefComplaint || null,
status: 'RESCHEDULED',
}, },
}, },
); );
// Propagate name change during reschedule. Same gate as the
// create branch — nameChanged implies isNameEditable=true,
// which means the agent went through EditPatientConfirmModal.
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
if (nameChanged) {
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
if (patientId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: patientId, data: { fullName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
if (leadId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ id: leadId, data: { contactName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update lead name:', err));
}
}
notify.success('Appointment Updated'); notify.success('Appointment Updated');
} else { } else {
// If no patient record exists yet (new caller), create one now
let resolvedPatientId = patientId;
if (!resolvedPatientId && callerNumber) {
const trimmedName = patientName.trim();
const nameParts = {
firstName: trimmedName.split(' ')[0] || '',
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
// Normalize phone to +91XXXXXXXXXX format
const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10);
const phoneE164 = `+91${phoneDigits}`;
try {
const patientData: Record<string, any> = {
fullName: nameParts,
phones: { primaryPhoneNumber: phoneE164 },
patientType: 'NEW',
};
if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0];
if (gender) patientData.gender = gender.toUpperCase();
const created = await apiClient.graphql<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: patientData },
);
resolvedPatientId = created.createPatient.id;
} catch (err) {
console.warn('Failed to create patient:', err);
}
}
// Create appointment // Create appointment
const appointmentData: Record<string, any> = {
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
...(clinic ? { clinicId: clinic } : {}),
...(agentNotes ? { agentNotes } : {}),
...(source ? { source } : {}),
};
console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData));
await apiClient.graphql( await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) { `mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id } createAppointment(data: $data) { id }
}`, }`,
{ { data: appointmentData },
data: {
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(patientId ? { patientId } : {}),
},
},
); );
// Update patient name if we have a name and a linked patient // Determine whether the agent actually renamed the patient.
if (patientId && patientName.trim()) { // Only a non-empty, changed-from-initial name counts — empty
await apiClient.graphql( // strings or an unchanged name never trigger the rename
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) { // chain, even if the field was unlocked.
updatePatient(id: $id, data: $data) { id } const trimmedName = patientName.trim();
}`, const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
{
id: patientId, // Update patient name when the agent explicitly renamed.
data: { // `nameChanged` already requires isNameEditable=true (the
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, // agent went through EditPatientConfirmModal), so the
}, // rename intent is unambiguous. Bug #527's silent-overwrite
}, // case can no longer happen because the confirm modal
// gates the input.
if (nameChanged && patientId) {
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: patientId, data: { fullName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update patient name:', err)); ).catch((err: unknown) => console.warn('Failed to update patient name:', err));
} }
// Update lead status + name if we have a matched lead // 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) { if (leadId) {
await apiClient.graphql( await apiClient.graphql(
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
@@ -269,21 +418,24 @@ export const AppointmentForm = ({
{ {
id: leadId, id: leadId,
data: { data: {
leadStatus: 'APPOINTMENT_SET', status: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(), lastContacted: new Date().toISOString(),
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}), ...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
}, },
}, },
).catch((err: unknown) => console.warn('Failed to update lead:', err)); ).catch((err: unknown) => console.warn('Failed to update lead:', err));
} }
// Invalidate caller cache so next lookup gets the real name // If the agent actually renamed the patient, kick off the
if (callerNumber) { // side-effect chain: regenerate the AI summary against the
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {}); // corrected identity. Fire-and-forget; the save toast
// fires immediately regardless.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} }
} }
onSaved?.(); onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED');
} catch (err) { } catch (err) {
console.error('Failed to save appointment:', err); console.error('Failed to save appointment:', err);
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.'); setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
@@ -306,7 +458,7 @@ export const AppointmentForm = ({
}, },
); );
notify.success('Appointment Cancelled'); notify.success('Appointment Cancelled');
onSaved?.(); onSaved?.('CANCELLED');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel appointment'); setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
} finally { } finally {
@@ -330,12 +482,34 @@ export const AppointmentForm = ({
</span> </span>
</div> </div>
<Input {/* Patient name — locked by default for existing
label="Patient Name" callers, unlocked for new callers with no
placeholder="Full name" prior name on record. The Edit button opens
value={patientName} a confirm modal before unlocking; see
onChange={setPatientName} 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={readOnly || !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"> <div className="grid grid-cols-2 gap-3">
<Input <Input
@@ -393,7 +567,7 @@ export const AppointmentForm = ({
items={departmentItems} items={departmentItems}
selectedKey={department} selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)} onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0} isDisabled={readOnly || doctors.length === 0}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -404,7 +578,7 @@ export const AppointmentForm = ({
items={doctorSelectItems} items={doctorSelectItems}
selectedKey={doctor} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department} isDisabled={readOnly || !department}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -416,7 +590,12 @@ export const AppointmentForm = ({
value={date ? parseDate(date) : null} value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')} onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day" granularity="day"
isDisabled={!doctor} isDisabled={readOnly || !doctor}
// Block past dates — appointments can't be booked or
// rescheduled into the past. React Aria's DatePicker
// honours minValue in both the calendar grid and the
// typed-input fallback.
minValue={today(getLocalTimeZone())}
/> />
</div> </div>
@@ -434,7 +613,7 @@ export const AppointmentForm = ({
<button <button
key={slot.id} key={slot.id}
type="button" type="button"
disabled={isBooked} disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)} onClick={() => setTimeSlot(slot.id)}
className={cx( className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear', 'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
@@ -462,6 +641,7 @@ export const AppointmentForm = ({
placeholder="Describe the reason for visit..." placeholder="Describe the reason for visit..."
value={chiefComplaint} value={chiefComplaint}
onChange={setChiefComplaint} onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2} rows={2}
/> />
@@ -498,7 +678,7 @@ export const AppointmentForm = ({
{/* Footer — pinned */} {/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary"> <div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div> <div>
{isEditMode && ( {isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}> <Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment Cancel Appointment
</Button> </Button>
@@ -508,11 +688,31 @@ export const AppointmentForm = ({
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}> <Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}> {!readOnly && (
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'} <Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
</Button> {isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
)}
</div> </div>
</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> </div>
); );
}; };

View File

@@ -0,0 +1,225 @@
import { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { Button } from '@/components/base/buttons/button';
import { supervisorSip } from '@/lib/supervisor-sip-client';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const HangupIcon = faIcon(faPhoneHangup);
const HeadsetIcon = faIcon(faHeadset);
type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended';
type BargeMode = 'listen' | 'whisper' | 'barge';
const MODE_DTMF: Record<BargeMode, string> = { listen: '4', whisper: '5', barge: '6' };
const MODE_CONFIG: Record<BargeMode, {
label: string;
description: string;
icon: any;
activeClass: string;
}> = {
listen: {
label: 'Listen',
description: 'Silent monitoring — nobody knows you are here',
icon: faHeadset,
activeClass: 'border-secondary bg-secondary',
},
whisper: {
label: 'Whisper',
description: 'Only the agent can hear you',
icon: faCommentDots,
activeClass: 'border-brand bg-brand-primary',
},
barge: {
label: 'Barge',
description: 'Both agent and patient can hear you',
icon: faUsers,
activeClass: 'border-error bg-error-primary',
},
};
type BargeControlsProps = {
ucid: string;
agentId: string;
agentNumber: string;
agentName: string;
onDisconnected?: () => void;
};
export const BargeControls = ({ ucid, agentId, agentNumber, agentName, onDisconnected }: BargeControlsProps) => {
const [status, setStatus] = useState<BargeStatus>('idle');
const [mode, setMode] = useState<BargeMode>('listen');
const [duration, setDuration] = useState(0);
const connectedAtRef = useRef<number | null>(null);
// Duration counter
useEffect(() => {
if (status !== 'connected') return;
connectedAtRef.current = Date.now();
const interval = setInterval(() => {
setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [status]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (supervisorSip.isCallActive()) {
supervisorSip.close();
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
}
};
}, [agentId]);
const handleConnect = async () => {
setStatus('connecting');
setMode('listen');
setDuration(0);
try {
const result = await apiClient.post<{
sipNumber: string;
sipPassword: string;
sipDomain: string;
sipPort: string;
}>('/api/supervisor/barge', { ucid, agentId, agentNumber });
supervisorSip.on('registered', () => {
// Ozonetel will send incoming call after SIP registration
});
supervisorSip.on('callConnected', () => {
setStatus('connected');
supervisorSip.sendDTMF('4'); // default: listen mode
notify.success('Connected', `Monitoring ${agentName}'s call`);
});
supervisorSip.on('callEnded', () => {
setStatus('ended');
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
onDisconnected?.();
});
supervisorSip.on('callFailed', (cause: string) => {
setStatus('ended');
notify.error('Connection Failed', cause ?? 'Could not connect to call');
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
});
supervisorSip.on('registrationFailed', (cause: string) => {
setStatus('ended');
notify.error('SIP Registration Failed', cause ?? 'Could not register');
});
supervisorSip.init({
domain: result.sipDomain,
port: result.sipPort,
number: result.sipNumber,
password: result.sipPassword,
});
supervisorSip.register();
} catch (err: any) {
setStatus('idle');
notify.error('Barge Failed', err.message ?? 'Could not initiate barge');
}
};
const handleModeChange = (newMode: BargeMode) => {
if (newMode === mode) return;
supervisorSip.sendDTMF(MODE_DTMF[newMode]);
setMode(newMode);
apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {});
};
const handleHangup = () => {
supervisorSip.close();
setStatus('ended');
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
onDisconnected?.();
};
const formatDuration = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
// Idle / ended state
if (status === 'idle' || status === 'ended') {
return (
<div className="flex flex-col items-center gap-3 py-6">
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary" />
<p className="text-sm text-secondary">{status === 'ended' ? 'Session ended' : 'Ready to monitor'}</p>
<Button size="sm" color="primary" iconLeading={HeadsetIcon} onClick={handleConnect}>
{status === 'ended' ? 'Reconnect' : 'Connect'}
</Button>
</div>
);
}
// Connecting state
if (status === 'connecting') {
return (
<div className="flex flex-col items-center gap-3 py-6">
<div className="flex items-center gap-2">
<span className="size-2 animate-pulse rounded-full bg-warning-solid" />
<span className="text-sm font-medium text-warning-primary">Connecting...</span>
</div>
<p className="text-xs text-tertiary">Registering SIP and joining call</p>
</div>
);
}
// Connected state
return (
<div className="flex flex-col gap-3">
{/* Status bar */}
<div className="flex items-center justify-between rounded-lg bg-success-primary px-3 py-2">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-success-solid" />
<span className="text-xs font-semibold text-success-primary">Connected</span>
</div>
<span className="font-mono text-xs text-success-primary">{formatDuration(duration)}</span>
</div>
{/* Mode tabs */}
<div className="flex gap-1">
{(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => {
const config = MODE_CONFIG[m];
const isActive = mode === m;
return (
<button
key={m}
onClick={() => handleModeChange(m)}
className={cx(
'flex flex-1 flex-col items-center gap-1 rounded-lg border-2 px-2 py-2.5 text-center transition duration-100 ease-linear',
isActive ? config.activeClass : 'border-secondary hover:bg-primary_hover',
)}
>
<FontAwesomeIcon
icon={config.icon}
className={cx('size-4', isActive ? 'text-fg-primary' : 'text-fg-quaternary')}
/>
<span className={cx('text-xs font-semibold', isActive ? 'text-primary' : 'text-tertiary')}>
{config.label}
</span>
</button>
);
})}
</div>
{/* Mode description */}
<p className="text-center text-xs text-tertiary">{MODE_CONFIG[mode].description}</p>
{/* Hang up */}
<Button size="sm" color="primary-destructive" iconLeading={HangupIcon} onClick={handleHangup} className="w-full">
Hang Up
</Button>
</div>
);
};

View File

@@ -14,11 +14,15 @@ interface CallLogProps {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' }, INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' }, NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' }, WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' }, NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
}; };
const formatDuration = (seconds: number | null): string => { const formatDuration = (seconds: number | null): string => {

View File

@@ -1,67 +1,34 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faSparkles, faPhone, faChevronDown, faChevronUp,
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
} from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from './ai-chat-panel'; import { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges'; import type { Appointment } from '@/types/entities';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
export type ContextPanelSubject = {
id: string;
contactName?: { firstName: string; lastName: string } | null;
contactPhone?: Array<{ number: string; callingCode: string }> | null;
patientId?: string | null;
leadSource?: string | null;
leadStatus?: string | null;
aiSummary?: string | null;
aiSuggestedAction?: string | null;
utmCampaign?: string | null;
campaignId?: string | null;
};
interface ContextPanelProps { interface ContextPanelProps {
selectedLead: Lead | null; selectedLead: ContextPanelSubject | null;
activities: LeadActivity[]; activities: any[];
calls: Call[]; calls: any[];
followUps: FollowUp[]; followUps: any[];
appointments: Appointment[]; appointments: Appointment[];
patients: Patient[]; patients: any[];
callerPhone?: string; callerPhone?: string;
isInCall?: boolean; isInCall?: boolean;
callUcid?: string | null; callUcid?: string | null;
} }
const formatTimeAgo = (dateStr: string): string => { export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const formatDuration = (sec: number): string => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
}) => (
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
>
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
{count !== undefined && count > 0 && (
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
)}
<FontAwesomeIcon
icon={expanded ? faChevronUp : faChevronDown}
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
/>
</button>
);
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true);
const [recentExpanded, setRecentExpanded] = useState(true);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null); const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const lead = selectedLead; const lead = selectedLead;
@@ -76,23 +43,8 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
leadName: fullName, leadName: fullName,
} : callerPhone ? { callerPhone } : undefined; } : callerPhone ? { callerPhone } : undefined;
// Filter data for this lead
const leadCalls = useMemo(() =>
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[calls, lead, callerPhone],
);
const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3),
[followUps, lead],
);
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId; const patientId = lead?.patientId;
if (!patientId) return []; if (!patientId) return [];
return appointments return appointments
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW') .filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
@@ -100,209 +52,23 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
.slice(0, 3); .slice(0, 3);
}, [appointments, lead]); }, [appointments, lead]);
const leadActivities = useMemo(() => const handleChatStart = useCallback(() => {}, []);
activities.filter(a => a.leadId === lead?.id)
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
.slice(0, 5),
[activities, lead],
);
// Linked patient // Edit mode takes over the whole right panel
const linkedPatient = useMemo(() => if (editingAppointment) {
patients.find(p => p.id === (lead as any)?.patientId), return (
[patients, lead], <div className="flex h-full flex-col">
); <div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
// Auto-collapse context sections when chat starts
const handleChatStart = useCallback(() => {
setContextExpanded(false);
}, []);
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
return (
<div className="flex h-full flex-col">
{/* Lead header — always visible */}
{lead && (
<div className="shrink-0 border-b border-secondary">
<button <button
onClick={() => setContextExpanded(!contextExpanded)} onClick={() => setEditingAppointment(null)}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear" className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
> >
{isInCall && ( Back to context
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
)}
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
{phone && (
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
)}
{lead.leadStatus && (
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
)}
<FontAwesomeIcon
icon={contextExpanded ? faChevronUp : faChevronDown}
className="size-3 text-fg-quaternary ml-auto shrink-0"
/>
</button> </button>
{/* Expanded context sections */}
{contextExpanded && (
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
{/* AI Insight */}
{lead.aiSummary && (
<div>
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
{insightExpanded && (
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
{lead.aiSuggestedAction && (
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
)}
</div>
)}
</div>
)}
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
<div>
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
{actionsExpanded && (
<div className="space-y-1 mb-1">
{leadAppointments.map(appt => (
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{appt.doctorName ?? 'Appointment'}
</span>
<span className="text-[11px] text-tertiary ml-1">
{appt.department}
</span>
{appt.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1">
{formatShortDate(appt.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
</Badge>
<button
onClick={() => setEditingAppointment(appt)}
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
>
Edit
</button>
</div>
))}
{leadFollowUps.map(fu => (
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
</span>
{fu.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1.5">
{formatShortDate(fu.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
</Badge>
</div>
))}
{linkedPatient && (
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
<span className="text-xs text-primary">
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
</span>
{linkedPatient.patientType && (
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Recent calls + activities */}
{(leadCalls.length > 0 || leadActivities.length > 0) && (
<div>
<SectionHeader
icon={faClockRotateLeft}
label="Recent"
count={leadCalls.length + leadActivities.length}
expanded={recentExpanded}
onToggle={() => setRecentExpanded(!recentExpanded)}
/>
{recentExpanded && (
<div className="space-y-0.5 mb-1">
{leadCalls.map(call => (
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
<FontAwesomeIcon
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
className={cx('size-3 shrink-0',
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
)}
/>
<div className="min-w-0 flex-1">
<span className="text-xs text-primary">
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
</span>
{call.durationSeconds != null && call.durationSeconds > 0 && (
<span className="text-[11px] text-tertiary ml-1"> {formatDuration(call.durationSeconds)}</span>
)}
{call.disposition && (
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
)}
</div>
<span className="text-[11px] text-quaternary shrink-0">
{formatTimeAgo(call.startedAt ?? call.createdAt)}
</span>
</div>
))}
{leadActivities
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
.slice(0, 3)
.map(a => (
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
{a.occurredAt && (
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
)}
</div>
))
}
</div>
)}
</div>
)}
{/* No context available */}
{!hasContext && (
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
)}
</div>
)}
</div> </div>
)}
{/* AI Chat — fills remaining space */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
</div>
{/* Appointment edit form */}
{editingAppointment && (
<AppointmentForm <AppointmentForm
isOpen={!!editingAppointment} isOpen={true}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }} onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone} callerNumber={callerPhone}
leadName={fullName} leadName={fullName}
@@ -314,12 +80,39 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
doctorName: editingAppointment.doctorName ?? '', doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined, doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '', department: editingAppointment.department ?? '',
clinicId: editingAppointment.clinicId ?? undefined,
reasonForVisit: editingAppointment.reasonForVisit ?? undefined, reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED', status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}} }}
onSaved={() => setEditingAppointment(null)} onSaved={() => setEditingAppointment(null)}
/> />
)} </div>
);
}
// Build callerSummary for the AI coaching panel
const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date());
const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED');
const callerSummary = lead ? {
name: fullName,
phone: phone?.number ?? callerPhone ?? '',
isNew: false,
aiSummary: (lead as any).aiSummary ?? null,
leadSource: (lead as any).leadSource ?? null,
utmCampaign: (lead as any).utmCampaign ?? null,
nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null,
lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null,
} : callerPhone ? {
name: '',
phone: callerPhone,
isNew: true,
} : null;
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
</div>
</div> </div>
); );
}; };

View File

@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white ring-transparent', activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success', defaultClass: 'bg-success-primary text-success-primary border-success',
}, },
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{ {
value: 'FOLLOW_UP_SCHEDULED', value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed', label: 'Follow-up Needed',
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary', defaultClass: 'bg-secondary text-secondary border-secondary',
}, },
{ {
value: 'CALLBACK_REQUESTED', value: 'NOT_INTERESTED',
label: 'Not Interested', label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent', activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error', defaultClass: 'bg-error-primary text-error-primary border-error',
}, },
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white ring-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
]; ];
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => { export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {

View File

@@ -1,13 +1,43 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { Badge } from '@/components/base/badges/badges';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import type { FC } from 'react'; import type { FC } from 'react';
import type { CallDisposition } from '@/types/entities'; import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY';
// Maps a recorded action to the disposition it implies. The first action in
// the priority list (highest-ranked entry in actionsTaken) becomes the
// primary disposition. When any action is present, all other dispositions
// are locked out — an agent can't mark a call as "Not Interested" after
// they've already booked an appointment.
const ACTION_TO_DISPOSITION: Record<CallAction, CallDisposition> = {
APPOINTMENT: 'APPOINTMENT_BOOKED',
RESCHEDULE: 'APPOINTMENT_RESCHEDULED',
CANCEL: 'APPOINTMENT_CANCELLED',
FOLLOWUP: 'FOLLOW_UP_SCHEDULED',
ENQUIRY: 'INFO_PROVIDED',
};
const ACTION_META: Record<CallAction, { label: string; icon: typeof faCalendarCheck; color: 'success' | 'warning' | 'error' | 'brand' | 'blue-light' }> = {
APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' },
RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' },
CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' },
FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' },
ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' },
};
// Priority order — highest-rank action wins when multiple are taken. Booked
// > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means
// no booking, so it ranks below booking/rescheduling; but above a follow-up
// because cancellation is a definitive outcome on this call.
const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY'];
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => ( const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneHangup} className={className} /> <FontAwesomeIcon icon={faPhoneHangup} className={className} />
); );
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white border-transparent', activeClass: 'bg-success-solid text-white border-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success', defaultClass: 'bg-success-primary text-success-primary border-success',
}, },
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white border-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{ {
value: 'FOLLOW_UP_SCHEDULED', value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed', label: 'Follow-up Needed',
@@ -49,31 +91,74 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary', defaultClass: 'bg-secondary text-secondary border-secondary',
}, },
{ {
value: 'CALLBACK_REQUESTED', value: 'NOT_INTERESTED',
label: 'Not Interested', label: 'Not Interested',
activeClass: 'bg-error-solid text-white border-transparent', activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error', defaultClass: 'bg-error-primary text-error-primary border-error',
}, },
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white border-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
{
value: 'CALL_DROPPED',
label: 'Call Dropped',
activeClass: 'bg-secondary-solid text-white border-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
]; ];
type DispositionModalProps = { type DispositionModalProps = {
isOpen: boolean; isOpen: boolean;
callerName: string; callerName: string;
callerDisconnected: boolean; callerDisconnected: boolean;
defaultDisposition?: CallDisposition | null; // True once the call reached the active (answered) state. When false,
// the customer never picked up — only no-answer dispositions are
// valid; conversation-implying ones (Info Provided, Appointment
// Booked, Follow-up, Not Interested) are disabled. Defaults to
// true so existing callers don't accidentally lock everything out.
callAnswered?: boolean;
// Actions actually performed during the call (appointment booked, enquiry
// logged, follow-up scheduled). Drives the priority-based disposition
// lock — when any action is present, the primary disposition is forced
// and the other options are disabled.
actionsTaken?: CallAction[];
onSubmit: (disposition: CallDisposition, notes: string) => void; onSubmit: (disposition: CallDisposition, notes: string) => void;
onDismiss?: () => void; onDismiss?: () => void;
}; };
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => { // Dispositions that only make sense when the customer actually connected.
// Selecting these on an unanswered call would misrepresent SLA and
// conversation metrics.
const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet<CallDisposition> = new Set([
'INFO_PROVIDED',
'APPOINTMENT_BOOKED',
'APPOINTMENT_RESCHEDULED',
'APPOINTMENT_CANCELLED',
'FOLLOW_UP_SCHEDULED',
'NOT_INTERESTED',
]);
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null); const [selected, setSelected] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined); const appliedLockRef = useRef<CallDisposition | null | undefined>(undefined);
// Pre-select when modal opens with a suggestion // Rank actionsTaken to pick the primary (highest-priority) action. When
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) { // any action is present, that action's disposition becomes locked —
appliedDefaultRef.current = defaultDisposition; // the agent cannot override it to a contradictory outcome.
setSelected(defaultDisposition); const primaryAction = actionsTaken && actionsTaken.length > 0
? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null
: null;
const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null;
// Apply the lock once per open — agent can still re-select the same
// option, but switching to another value is prevented in the click handler.
if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) {
appliedLockRef.current = lockedDisposition;
setSelected(lockedDisposition);
} }
const handleSubmit = () => { const handleSubmit = () => {
@@ -81,11 +166,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
onSubmit(selected, notes); onSubmit(selected, notes);
setSelected(null); setSelected(null);
setNotes(''); setNotes('');
appliedDefaultRef.current = undefined; appliedLockRef.current = undefined;
}; };
return ( return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}> <ModalOverlay
isOpen={isOpen}
// When the caller disconnected on their own, dismissing the
// modal discards the call without any disposition — no record,
// no SLA signal. Force a selection in that path. When the
// agent opened the modal via End Call (callerDisconnected=false),
// dismissing just returns to the active call, so it's safe.
isDismissable={!callerDisconnected}
onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}
>
<Modal className="sm:max-w-md"> <Modal className="sm:max-w-md">
<Dialog> <Dialog>
{() => ( {() => (
@@ -108,16 +202,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
{/* Disposition options */} {/* Disposition options */}
<div className="px-6 pb-4"> <div className="px-6 pb-4">
{actionsTaken && actionsTaken.length > 0 && (
<div className="mb-3 flex flex-col gap-2 rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold uppercase tracking-wide text-tertiary">
Actions taken on this call
</span>
<div className="flex flex-wrap gap-1.5">
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => {
const meta = ACTION_META[action];
return (
<Badge key={action} size="sm" color={meta.color} type="pill-color">
<FontAwesomeIcon icon={meta.icon} className="size-3 mr-1" />
{meta.label}
</Badge>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{dispositionOptions.map((option) => { {dispositionOptions.map((option) => {
const isSelected = selected === option.value; const isSelected = selected === option.value;
// Two reasons an option can be disabled:
// (1) action lock — the agent already booked / scheduled
// something, so only the matching disposition is valid.
// (2) unanswered call — dispositions that imply the customer
// actually spoke with the agent (Info Provided, etc.)
// are disabled to prevent SLA-gaming.
const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition;
const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value);
const isDisabled = isLockedOut || isAnsweredOnlyBlocked;
return ( return (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setSelected(option.value)} disabled={isDisabled}
onClick={() => !isDisabled && setSelected(option.value)}
className={cx( className={cx(
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear', 'rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
isDisabled && 'cursor-not-allowed opacity-40',
!isDisabled && 'cursor-pointer',
isSelected isSelected
? cx(option.activeClass, 'ring-2 ring-brand') ? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass, : option.defaultClass,

View File

@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'; 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 { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -11,15 +14,30 @@ type EnquiryFormProps = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
callerPhone?: string | null; 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; leadId?: string | null;
patientId?: string | null; patientId?: string | null;
agentName?: string | null; agentName?: string | null;
onSaved?: () => void; // Called after a successful save. Passes back the list of actions that
// were actually recorded — the parent uses this to drive the disposition
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
// the agent scheduled a callback.
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
}; };
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => { export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState(''); // 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 [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState(''); const [queryAsked, setQueryAsked] = useState('');
const [isExisting, setIsExisting] = useState(false); const [isExisting, setIsExisting] = useState(false);
@@ -65,61 +83,104 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
setError(null); setError(null);
try { try {
// Use passed leadId or resolve from phone // Resolve caller. Resolver returns isNew=true when no Lead/
// Patient exists for this phone — in that case we create both
// records inline with the typed name. Otherwise we update the
// existing records.
let leadId: string | null = propLeadId ?? null; let leadId: string | null = propLeadId ?? null;
if (!leadId && registeredPhone) { let resolvedPatientId: string | null = patientId || null;
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true }); let isNew = false;
leadId = resolved.leadId; if ((!leadId || !resolvedPatientId) && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = leadId || resolved.leadId || null;
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
isNew = !!resolved.isNew && !leadId;
} }
if (leadId) { const trimmedName = patientName.trim();
// Update existing lead with enquiry details const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
const nameParts = {
firstName: trimmedName.split(' ')[0],
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
if (isNew) {
// Net-new caller — create Patient + Lead with the typed
// name. Name is required (validated above).
if (!trimmedName) {
setError('Please enter the patient name.');
setIsSaving(false);
return;
}
try {
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
const patientData: Record<string, any> = {
name: trimmedName,
fullName: nameParts,
patientType: 'NEW',
};
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: patientData },
);
resolvedPatientId = pResult.createPatient.id;
} catch (err) {
console.warn('Failed to create patient:', err);
}
const leadData: Record<string, any> = {
name: `Enquiry — ${trimmedName}`,
contactName: nameParts,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
};
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: leadData },
);
leadId = lResult.createLead.id;
} else if (leadId) {
// Existing lead — update with enquiry details. Only touch
// contactName when the agent explicitly renamed (the name
// field is locked behind the Edit confirm modal for
// existing records).
await apiClient.graphql( await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ {
id: leadId, id: leadId,
data: { data: {
name: `Enquiry — ${patientName}`, name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
} else {
// No phone provided — create a new lead (rare edge case)
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(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE', source: 'PHONE',
status: 'CONTACTED', status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100), interestedService: queryAsked.substring(0, 100),
...(nameChanged ? { contactName: nameParts } : {}),
}, },
}, },
); );
} }
// Update patient name if we have a name and a linked patient // Update linked patient's name when the agent renamed (edit
if (patientId && patientName.trim()) { // confirm path) on an existing record. Skipped for isNew
// because the patient was just created with the right name.
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
await apiClient.graphql( await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ {
id: patientId, id: resolvedPatientId,
data: { 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)); ).catch((err: unknown) => console.warn('Failed to update patient name:', err));
} }
// Invalidate caller cache so next lookup gets the real name // Post-save side-effect. If the agent actually renamed the
if (callerPhone) { // patient, kick off AI summary regen. Fire-and-forget.
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {}); if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} }
// Create follow-up if needed // Create follow-up if needed
@@ -129,6 +190,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
setIsSaving(false); setIsSaving(false);
return; return;
} }
const today = new Date().toISOString().split('T')[0];
if (followUpDate < today) {
setError('Follow-up date cannot be in the past.');
setIsSaving(false);
return;
}
await apiClient.graphql( await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, `mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{ {
@@ -139,7 +206,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
priority: 'NORMAL', priority: 'NORMAL',
assignedAgent: agentName ?? undefined, assignedAgent: agentName ?? undefined,
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(), scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
patientId: patientId ?? undefined, patientId: resolvedPatientId || undefined,
}, },
}, },
{ silent: true }, { silent: true },
@@ -147,7 +214,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
} }
notify.success('Enquiry Logged', 'Contact details and query captured'); notify.success('Enquiry Logged', 'Contact details and query captured');
onSaved?.(); const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
if (followUpNeeded) actions.push('FOLLOWUP');
onSaved?.(actions);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save enquiry'); setError(err instanceof Error ? err.message : 'Failed to save enquiry');
} finally { } finally {
@@ -162,7 +231,34 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
{/* Form fields — scrollable */} {/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3"> <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 /> <Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
@@ -187,11 +283,22 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
</Select> </Select>
</div> </div>
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" /> <div className="flex items-center gap-3">
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && ( {followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired /> <div className="flex-1 max-w-[180px]">
)} <input
type="date"
value={followUpDate}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setFollowUpDate(e.target.value)}
required
aria-label="Follow-up Date"
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
/>
</div>
)}
</div>
{error && ( {error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div> <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
@@ -206,6 +313,24 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
{isSaving ? 'Saving...' : 'Log Enquiry'} {isSaving ? 'Saving...' : 'Log Enquiry'}
</Button> </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 logging the
enquiry as-is.
</>
}
/>
</div> </div>
); );
}; };

View File

@@ -51,11 +51,15 @@ const ActivityIcon = ({ type }: { type: string }) => {
const dispositionLabels: Record<CallDisposition, string> = { const dispositionLabels: Record<CallDisposition, string> = {
APPOINTMENT_BOOKED: 'Appointment Booked', APPOINTMENT_BOOKED: 'Appointment Booked',
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
FOLLOW_UP_SCHEDULED: 'Follow-up Needed', FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
INFO_PROVIDED: 'Info Provided', INFO_PROVIDED: 'Info Provided',
NO_ANSWER: 'No Answer', NO_ANSWER: 'No Answer',
WRONG_NUMBER: 'Wrong Number', WRONG_NUMBER: 'Wrong Number',
CALLBACK_REQUESTED: 'Not Interested', NOT_INTERESTED: 'Not Interested',
CALLBACK_REQUESTED: 'Callback Requested',
CALL_DROPPED: 'Call Dropped',
}; };
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => { export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {

View File

@@ -74,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
{/* Clickable phone number — calls directly */} {/* Clickable phone number — calls directly */}
<button <button
type="button" type="button"
onClick={handleCall} onClick={canCall ? handleCall : undefined}
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }} onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
disabled={!canCall}
className={cx( className={cx(
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear', 'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-brand-secondary transition duration-100 ease-linear',
canCall canCall
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary' ? 'cursor-pointer hover:bg-brand-primary'
: 'cursor-default text-tertiary', : 'cursor-default',
)} )}
> >
<FontAwesomeIcon icon={faPhone} className="size-3" /> <FontAwesomeIcon icon={faPhone} className="size-3" />
<span className="whitespace-nowrap">{displayNumber}</span> <span className="whitespace-nowrap">{displayNumber}</span>
</button> </button>
{/* Kebab menu trigger — desktop */} {/* Kebab menu trigger — SMS + WhatsApp */}
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }} 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" className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
> >
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" /> <FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
</button> </button>
{/* Context menu */} {/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
{menuOpen && ( {menuOpen && (
<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"> <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}
disabled={!canCall}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
Call
</button>
<button <button
type="button" type="button"
onClick={handleSms} onClick={handleSms}

View File

@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
const fetchTargets = async () => { const fetchTargets = async () => {
try { try {
const [agentsRes, doctorsRes] = await Promise.all([ const [agentsRes, doctorsRes] = await Promise.all([
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`), apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`),
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`), apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
]); ]);
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? []) const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
.map((e: any) => e.node) .map((e: any) => e.node)
.filter((a: any) => a.ozonetelagentid !== currentAgentId) .filter((a: any) => a.ozonetelAgentId !== currentAgentId)
.map((a: any) => ({ .map((a: any) => ({
id: a.id, id: a.id,
name: a.name, name: a.name,
type: 'agent' as const, type: 'agent' as const,
phoneNumber: `0${a.sipextension}`, phoneNumber: `0${a.sipExtension}`,
status: 'offline' as const, status: 'offline' as const,
})); }));

View File

@@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components'; import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell'; import { PhoneActionCell } from './phone-action-cell';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format'; import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -36,6 +32,9 @@ type WorklistFollowUp = {
followUpStatus: string | null; followUpStatus: string | null;
scheduledAt: string | null; scheduledAt: string | null;
priority: string | null; priority: string | null;
patientId?: string | null;
patientName?: string;
patientPhone?: string;
}; };
type MissedCall = { type MissedCall = {
@@ -45,23 +44,40 @@ type MissedCall = {
callerNumber: { number: string; callingCode: string }[] | null; callerNumber: { number: string; callingCode: string }[] | null;
startedAt: string | null; startedAt: string | null;
leadId: string | null; leadId: string | null;
leadName: string | null;
disposition: string | null; disposition: string | null;
callbackstatus: string | null; callbackStatus: string | null;
callsourcenumber: string | null; callSourceNumber: string | null;
missedcallcount: number | null; missedCallCount: number | null;
callbackattemptedat: string | null; callbackAttemptedAt: string | null;
campaign?: { id: string; campaignName: string } | null;
}; };
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
// Generic selection from any worklist row — the call-desk resolves
// lead/patient context from whatever is available on the row.
export type WorklistSelection = {
rowId: string;
type: 'missed' | 'callback' | 'follow-up' | 'lead';
lead: WorklistLead | null;
phoneRaw: string | null;
patientId: string | null;
leadId: string | null;
name: string;
};
interface WorklistPanelProps { interface WorklistPanelProps {
missedCalls: MissedCall[]; missedCalls: MissedCall[];
followUps: WorklistFollowUp[]; followUps: WorklistFollowUp[];
leads: WorklistLead[]; leads: WorklistLead[];
loading: boolean; loading: boolean;
onSelectLead: (lead: WorklistLead) => void; onSelectItem: (selection: WorklistSelection) => void;
selectedLeadId: string | null; selectedItemId: string | null;
onDialMissedCall?: (missedCallId: string) => void; onDialMissedCall?: (missedCallId: string) => void;
// Lifted from internal state — owned by call-desk.tsx so the search
// input can live in the PageHeader row alongside other controls.
search: string;
} }
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
@@ -79,6 +95,7 @@ type WorklistRow = {
createdAt: string; createdAt: string;
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED'; taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
leadId: string | null; leadId: string | null;
patientId: string | null;
originalLead: WorklistLead | null; originalLead: WorklistLead | null;
lastContactedAt: string | null; lastContactedAt: string | null;
contactAttempts: number; contactAttempts: number;
@@ -107,7 +124,9 @@ const followUpLabel: Record<string, string> = {
REVIEW_REQUEST: 'Review', REVIEW_REQUEST: 'Review',
}; };
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { // SLA for reactive work — missed calls / unanswered leads. Measures time
// elapsed since the trigger: longer wait = worse SLA.
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 1) return { label: '<1m', color: 'success' }; if (minutes < 1) return { label: '<1m', color: 'success' };
if (minutes < 15) return { label: `${minutes}m`, color: 'success' }; if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
@@ -118,6 +137,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
return { label: `${Math.floor(hours / 24)}d`, color: 'error' }; return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
}; };
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
// until the scheduled slot. Green when comfortably ahead, warning when
// due soon, error when overdue.
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
if (minutes < 0) {
const overdueMins = -minutes;
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
const overdueHrs = Math.floor(overdueMins / 60);
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
}
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
};
const computeSla = (
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
): { label: string; color: 'success' | 'warning' | 'error' } => {
if (row.type === 'follow-up' || row.type === 'callback') {
// scheduledAt was written into lastContactedAt during row construction.
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
}
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
};
const formatTimeAgo = (dateStr: string): string => { const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000); const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now'; if (minutes < 1) return 'Just now';
@@ -130,17 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
const formatDisposition = (disposition: string): string => const formatDisposition = (disposition: string): string =>
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatSource = (source: string): string => { // formatSource + formatDid kept for reference but no longer rendered
const map: Record<string, string> = { // in the table — SOURCE/BRANCH column removed from display per user
FACEBOOK_AD: 'Facebook', // request. Data stays on the row for future use.
GOOGLE_AD: 'Google',
WALK_IN: 'Walk-in',
REFERRAL: 'Referral',
WEBSITE: 'Website',
PHONE_INQUIRY: 'Phone',
};
return map[source] ?? source.replace(/_/g, ' ');
};
const IconInbound = faIcon(faPhoneArrowDown); const IconInbound = faIcon(faPhoneArrowDown);
const IconOutbound = faIcon(faPhoneArrowUp); const IconOutbound = faIcon(faPhoneArrowUp);
@@ -150,13 +189,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const call of missedCalls) { for (const call of missedCalls) {
const phone = call.callerNumber?.[0]; const phone = call.callerNumber?.[0];
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : ''; const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : ''; const sourceSuffix = call.callSourceNumber ? `${call.callSourceNumber}` : '';
rows.push({ rows.push({
id: `mc-${call.id}`, id: `mc-${call.id}`,
type: 'missed', type: 'missed',
priority: 'HIGH', priority: 'HIGH',
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge, name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
phone: phone ? formatPhone(phone) : '', phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '', phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
@@ -165,12 +204,16 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}` ? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
: 'Missed call', : 'Missed call',
createdAt: call.createdAt, createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId, leadId: call.leadId,
patientId: (call as any).patientId ?? null,
originalLead: null, originalLead: null,
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt, lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
contactAttempts: 0, contactAttempts: 0,
source: call.callsourcenumber ?? null, // Branch column: prefer the campaign name (e.g. "Cervical Cancer
// Screening Drive") over the raw DID. Falls back to formatted DID
// for organic calls with no campaign.
source: call.campaign?.campaignName ?? call.callSourceNumber ?? null,
lastDisposition: call.disposition ?? null, lastDisposition: call.disposition ?? null,
missedCallId: call.id, missedCallId: call.id,
}); });
@@ -179,13 +222,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const fu of followUps) { for (const fu of followUps) {
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date()); const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up'; const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
// Sidecar enriches follow-ups with patient name/phone when a
// patientId is linked. Fall back to the generic type label when
// no patient is attached.
const displayName = fu.patientName?.trim() || label;
const phoneFormatted = fu.patientPhone
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
: '';
rows.push({ rows.push({
id: `fu-${fu.id}`, id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up', type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'), priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
name: label, name: displayName,
phone: '', phone: phoneFormatted,
phoneRaw: '', phoneRaw: fu.patientPhone ?? '',
direction: null, direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up', typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
reason: fu.scheduledAt reason: fu.scheduledAt
@@ -194,6 +244,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(), createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'), taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
leadId: null, leadId: null,
patientId: fu.patientId ?? null,
originalLead: null, originalLead: null,
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null, lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
contactAttempts: 0, contactAttempts: 0,
@@ -221,6 +272,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: lead.createdAt, createdAt: lead.createdAt,
taskState: 'PENDING', taskState: 'PENDING',
leadId: lead.id, leadId: lead.id,
patientId: (lead as any).patientId ?? null,
originalLead: lead, originalLead: lead,
lastContactedAt: lead.lastContacted ?? null, lastContactedAt: lead.lastContacted ?? null,
contactAttempts: lead.contactAttempts ?? 0, contactAttempts: lead.contactAttempts ?? 0,
@@ -230,8 +282,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
}); });
} }
// Remove rows without a phone number — agent can't act on them // Keep all rows — follow-ups may have no phone and still need to be visible.
const actionableRows = rows.filter(r => r.phoneRaw); // The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
const actionableRows = rows;
// Sort by rules engine score if available, otherwise by priority + createdAt // Sort by rules engine score if available, otherwise by priority + createdAt
actionableRows.sort((a, b) => { actionableRows.sort((a, b) => {
@@ -245,17 +298,21 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
return actionableRows; return actionableRows;
}; };
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState(''); // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending'); // sub-tabs were removed per QA feedback — pending callbacks are the only
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' }); // ones agents need to act on from the worklist.
const missedSubTab: MissedSubTab = 'pending';
// Default SLA sort is ascending — the bucket-sorted result puts the
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
const missedByStatus = useMemo(() => ({ const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus), pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'), attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'), completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'), invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
}), [missedCalls]); }), [missedCalls]);
const allRows = useMemo( const allRows = useMemo(
@@ -273,7 +330,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
let rows = allRows; let rows = allRows;
if (tab === 'missed') rows = missedSubTabRows; if (tab === 'missed') rows = missedSubTabRows;
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead'); else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
@@ -295,8 +352,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
case 'name': case 'name':
return a.name.localeCompare(b.name) * dir; return a.name.localeCompare(b.name) * dir;
case 'sla': { case 'sla': {
// Mixed SLA sort: SLA means different things by row type
// (elapsed for reactive, remaining for scheduled). Bucket
// rows by urgency, then sort within bucket — Overdue
// first, then reactive (oldest-first), then scheduled
// (soonest-due first). `dir` flips the whole ordering
// so the user can still toggle ascending/descending.
const urgencyBucket = (row: WorklistRow): number => {
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
if (isScheduled) {
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
}
return 1; // reactive (missed / lead)
};
const ba = urgencyBucket(a);
const bb = urgencyBucket(b);
if (ba !== bb) return (ba - bb) * dir;
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime(); const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime(); const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
// Within a bucket, ascending time = most urgent first
// (oldest overdue, oldest reactive, soonest upcoming).
return (ta - tb) * dir; return (ta - tb) * dir;
} }
default: default:
@@ -310,7 +386,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const missedCount = allRows.filter((r) => r.type === 'missed').length; const missedCount = allRows.filter((r) => r.type === 'missed').length;
const leadCount = allRows.filter((r) => r.type === 'lead').length; const leadCount = allRows.filter((r) => r.type === 'lead').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
// Notification for new missed calls // Notification for new missed calls
const prevMissedCount = useRef(missedCount); const prevMissedCount = useRef(missedCount);
@@ -324,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Reset page when search changes from parent
useEffect(() => { setPage(1); }, [search]);
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -358,49 +436,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */} {/* Filter pills — custom buttons matching All Leads pattern */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5"> <div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}> {tabItems.map((item) => (
<TabList items={tabItems} type="underline" size="sm"> <button
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} key={item.id}
</TabList> onClick={() => handleTabChange(item.id)}
</Tabs> className={cx(
<div className="w-44 shrink-0"> 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
<Input tab === item.id
placeholder="Search..." ? 'bg-brand-solid text-white'
icon={SearchLg} : 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
size="sm" )}
value={search} >
onChange={handleSearch} {item.label}{item.badge ? ` (${item.badge})` : ''}
aria-label="Search worklist" </button>
/> ))}
</div>
</div> </div>
{/* Missed call status sub-tabs */} {/* Missed-call sub-tabs removed per QA feedback — the Missed tab
{tab === 'missed' && ( now only shows pending callbacks. Attempted is redundant once
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary"> the worklist is the single source of truth. */}
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
onClick={() => { setMissedSubTab(sub); setPage(1); }}
className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
missedSubTab === sub
? 'bg-brand-50 text-brand-700 border border-brand-200'
: 'text-tertiary hover:text-secondary hover:bg-secondary',
)}
>
{sub}
{sub === 'pending' && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
{missedByStatus.pending.length}
</span>
)}
</button>
))}
</div>
)}
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -415,14 +471,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting /> <Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
<Table.Head id="name" label="PATIENT" allowsSorting /> <Table.Head id="name" label="PATIENT" allowsSorting />
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting /> <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(row) => { {(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.lastContactedAt ?? row.createdAt); const sla = computeSla(row);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; const isSelected = row.id === selectedItemId;
// Sub-line: last interaction context // Sub-line: last interaction context
const subLine = row.lastContactedAt const subLine = row.lastContactedAt
@@ -437,7 +492,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
onAction={() => { onAction={() => {
if (row.originalLead) onSelectLead(row.originalLead); onSelectItem({
rowId: row.id,
type: row.type,
lead: row.originalLead,
phoneRaw: row.phoneRaw || null,
patientId: row.patientId,
leadId: row.leadId,
name: row.name,
});
}} }}
> >
<Table.Cell> <Table.Cell>
@@ -488,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<span className="text-xs text-quaternary italic">No phone</span> <span className="text-xs text-quaternary italic">No phone</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell>
{row.source ? (
<span className="text-xs text-tertiary truncate block max-w-[100px]">
{formatSource(row.source)}
</span>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color"> <Badge size="sm" color={sla.color} type="pill-color">
{sla.label} {sla.label}

View File

@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
View on Platform View on Platform
</Button> </Button>
)} )}
<Button
color="primary"
size="sm"
href={`/leads`}
>
View Leads
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -26,14 +26,27 @@ interface AgentTableProps {
export const AgentTable = ({ calls }: AgentTableProps) => { export const AgentTable = ({ calls }: AgentTableProps) => {
const agents = useMemo(() => { const agents = useMemo(() => {
const agentMap = new Map<string, Call[]>(); // Bucket by authoritative agent.id when present (from CDR enrichment);
// fall back to raw agentName for legacy rows that haven't been
// enriched yet. Skips rows with no agent info at all.
const agentMap = new Map<string, { displayName: string; calls: Call[] }>();
for (const call of calls) { for (const call of calls) {
const agent = call.agentName ?? 'Unknown'; let key: string;
if (!agentMap.has(agent)) agentMap.set(agent, []); let displayName: string;
agentMap.get(agent)!.push(call); if (call.agent?.id) {
key = call.agent.id;
displayName = call.agent.name ?? call.agent.ozonetelAgentId ?? 'Unknown';
} else if (call.agentName) {
key = `legacy:${call.agentName}`;
displayName = call.agentName;
} else {
continue;
}
if (!agentMap.has(key)) agentMap.set(key, { displayName, calls: [] });
agentMap.get(key)!.calls.push(call);
} }
return Array.from(agentMap.entries()).map(([name, agentCalls]) => { return Array.from(agentMap.entries()).map(([key, { displayName, calls: agentCalls }]) => {
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length; const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length; const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length; const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
@@ -43,11 +56,11 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
const conversion = total > 0 ? (booked / total) * 100 : 0; const conversion = total > 0 ? (booked / total) * 100 : 0;
const nameParts = name.split(' '); const nameParts = displayName.split(' ');
return { return {
id: name, id: key,
name, name: displayName,
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''), initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
inbound, outbound, missed, total, avgHandle, conversion, inbound, outbound, missed, total, avgHandle, conversion,
}; };
@@ -82,7 +95,7 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
{(agent) => ( {(agent) => (
<Table.Row id={agent.id}> <Table.Row id={agent.id}>
<Table.Cell> <Table.Cell>
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline"> <Link to={`/agent/${encodeURIComponent(agent.id)}`} className="no-underline">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar size="xs" initials={agent.initials} /> <Avatar size="xs" initials={agent.initials} />
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span> <span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>

View File

@@ -0,0 +1,401 @@
import { useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
// Shared rollup surfaces for the supervisor dashboard: agent performance
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
// so both the Team Dashboard and the legacy Team Performance page render
// identically from a single data fetch.
type DateRange = 'today' | 'week' | 'month' | 'year';
type AgentPerf = {
name: string;
ozonetelAgentId: string;
npsScore: number | null;
maxIdleMinutes: number | null;
minNpsThreshold: number | null;
minConversionPercent: number | null;
calls: number;
inbound: number;
missed: number;
followUps: number;
leads: number;
appointments: number;
convPercent: number;
idleMinutes: number;
activeMinutes: number;
wrapMinutes: number;
breakMinutes: number;
};
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
const now = new Date();
const lte = now.toISOString();
const start = new Date(now);
if (range === 'today') start.setHours(0, 0, 0, 0);
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
return { gte: start.toISOString(), lte };
};
const parseTime = (timeStr: string): number => {
if (!timeStr) return 0;
const parts = timeStr.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
export const useSupervisorRollup = (range: DateRange) => {
const [agents, setAgents] = useState<AgentPerf[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const { gte, lte } = getDateRange(range);
const dateStr = new Date().toISOString().split('T')[0];
try {
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
]);
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
const teamAgents = teamData?.agents ?? [];
let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) {
agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => {
if (c.agentId && c.agentId === agent.id) return true;
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
return false;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
const tb = agent.timeBreakdown;
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return {
name: agent.name ?? agent.ozonetelAgentId,
ozonetelAgentId: agent.ozonetelAgentId,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversionPercent,
calls: totalCalls,
inbound,
missed,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: agentAppts,
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
idleMinutes: Math.round(idleSec / 60),
activeMinutes: Math.round(activeSec / 60),
wrapMinutes: Math.round(wrapSec / 60),
breakMinutes: Math.round(breakSec / 60),
};
});
} else {
const byKey = new Map<string, { key: string; name: string }>();
for (const c of calls) {
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
}
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
const agentCalls = calls.filter((c: any) => {
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
return c.agent?.id === key;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
return {
name,
ozonetelAgentId: name,
npsScore: null,
maxIdleMinutes: null,
minNpsThreshold: null,
minConversionPercent: null,
calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: completed,
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
idleMinutes: 0,
activeMinutes: 0,
wrapMinutes: 0,
breakMinutes: 0,
};
});
}
setAgents(agentPerfs);
} catch (err) {
console.error('Failed to load supervisor rollup:', err);
} finally {
setLoading(false);
}
};
load();
}, [range]);
return { agents, loading };
};
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Calls" />
<Table.Head label="Inbound" />
<Table.Head label="Missed" />
<Table.Head label="Follow-ups" />
<Table.Head label="Leads" />
<Table.Head label="Conv%" />
<Table.Head label="NPS" />
<Table.Head label="Idle" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.ozonetelAgentId || agent.name}>
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
{agent.convPercent}%
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsScore ?? '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m
</span>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
const teamAvg = useMemo(() => {
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
return {
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
};
}, [agents]);
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
// totals varied wildly, making the visual width comparison meaningless.
// Rendered as a table so the numbers speak for themselves; team-average
// row sits at the top as the reference point.
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable requires Ozonetel agent session data.</p>
)}
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Active" />
<Table.Head label="Wrap" />
<Table.Head label="Idle" />
<Table.Head label="Break" />
<Table.Head label="Total" />
</Table.Header>
<Table.Body
items={[
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
]}
>
{(item) => {
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
const total = active + wrap + idle + breakM;
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
return (
<Table.Row id={item.id}>
<Table.Cell>
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
{item.name}
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
{idle}m
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
);
};
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]);
const npsOption = useMemo(() => ({
tooltip: { trigger: 'item' },
series: [{
type: 'gauge', startAngle: 180, endAngle: 0,
min: 0, max: 100,
pointer: { show: false },
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
data: [{ value: avgNps }],
}],
}), [avgNps]);
return (
<div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
{agents.every(a => a.npsScore == null) ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p>
</div>
) : (
<>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2">
{agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
</div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div>
))}
</div>
</>
)}
</div>
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
<div className="flex gap-3 mb-4">
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
<p className="text-xs text-tertiary">Call Appointment</p>
</div>
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
</p>
<p className="text-xs text-tertiary">Lead Contact</p>
</div>
</div>
<div className="space-y-1">
{agents.map(a => (
<div key={a.name} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-28 truncate">{a.name}</span>
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
</div>
))}
</div>
</div>
</div>
);
};
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) {
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
}
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
}
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
}
}
return list;
}, [agents]);
if (alerts.length === 0) return null;
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-error-primary mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
Performance Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.map((alert, i) => (
<div key={i} className={cx(
'flex items-center justify-between rounded-lg px-4 py-3',
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
)}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
<span className="text-sm font-medium text-primary">{alert.agent}</span>
<span className="text-sm text-secondary"> {alert.type}</span>
</div>
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
// AI assistant form — mirrors AiConfig in
// helix-engage-server/src/config/ai.defaults.ts. API keys stay in env vars
// (true secrets, rotated at the infra level); everything the admin can safely
// adjust lives here: provider choice, model, temperature, and an optional
// system-prompt addendum appended to the hospital-specific prompts that the
// WidgetChatService generates.
export type AiProvider = 'openai' | 'anthropic';
export type AiFormValues = {
provider: AiProvider;
model: string;
temperature: string;
systemPromptAddendum: string;
};
export const emptyAiFormValues = (): AiFormValues => ({
provider: 'openai',
model: 'gpt-4o-mini',
temperature: '0.7',
systemPromptAddendum: '',
});
const PROVIDER_ITEMS = [
{ id: 'openai', label: 'OpenAI' },
{ id: 'anthropic', label: 'Anthropic' },
];
// Recommended model presets per provider. Admin can still type any model
// string they want — these are suggestions, not the only options.
export const MODEL_SUGGESTIONS: Record<AiProvider, string[]> = {
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
};
type AiFormProps = {
value: AiFormValues;
onChange: (value: AiFormValues) => void;
};
export const AiForm = ({ value, onChange }: AiFormProps) => {
const patch = (updates: Partial<AiFormValues>) => onChange({ ...value, ...updates });
const suggestions = MODEL_SUGGESTIONS[value.provider];
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Provider & model</h3>
<p className="mt-1 text-xs text-tertiary">
Choose the AI vendor powering the website widget chat and call-summary features.
Changing providers takes effect immediately.
</p>
</div>
<Select
label="Provider"
placeholder="Select provider"
items={PROVIDER_ITEMS}
selectedKey={value.provider}
onSelectionChange={(key) => {
const next = key as AiProvider;
// When switching providers, also reset the model to the first
// suggested model for that provider — saves the admin a second
// edit step and avoids leaving an OpenAI model selected while
// provider=anthropic.
patch({
provider: next,
model: MODEL_SUGGESTIONS[next][0],
});
}}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<div>
<Input
label="Model"
placeholder="Model identifier"
value={value.model}
onChange={(v) => patch({ model: v })}
/>
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestions.map((model) => (
<button
key={model}
type="button"
onClick={() => patch({ model })}
className={`rounded-md border px-2 py-1 text-xs transition duration-100 ease-linear ${
value.model === model
? 'border-brand bg-brand-secondary text-brand-secondary'
: 'border-secondary bg-primary text-tertiary hover:bg-secondary'
}`}
>
{model}
</button>
))}
</div>
</div>
<Input
label="Temperature"
type="number"
placeholder="0.7"
hint="0 = deterministic, 1 = balanced, 2 = very creative"
value={value.temperature}
onChange={(v) => patch({ temperature: v })}
/>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">System prompt addendum</h3>
<p className="mt-1 text-xs text-tertiary">
Optional gets appended to the hospital-specific prompts the widget generates
automatically from your doctors and clinics. Use this to add tone guidelines,
escalation rules, or topics the assistant should avoid. Leave blank for the default
behaviour.
</p>
</div>
<TextArea
label="Additional instructions"
placeholder="e.g. Always respond in the patient's language. Never quote specific medication dosages; refer them to a doctor for prescriptions."
value={value.systemPromptAddendum}
onChange={(v) => patch({ systemPromptAddendum: v })}
rows={6}
/>
</section>
</div>
);
};

View File

@@ -0,0 +1,517 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { parseDate, getLocalTimeZone, today } from '@internationalized/date';
import type { DateValue } from 'react-aria-components';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { Toggle } from '@/components/base/toggle/toggle';
import { Button } from '@/components/base/buttons/button';
import { DatePicker } from '@/components/application/date-picker/date-picker';
import { TimePicker } from '@/components/application/date-picker/time-picker';
import {
DaySelector,
defaultDaySelection,
type DaySelection,
} from '@/components/application/day-selector/day-selector';
// Reusable clinic form used by /settings/clinics slideout and the /setup
// wizard step. The parent owns form state + the save flow so it can decide
// how to orchestrate the multi-step create chain (one createClinic, then one
// createHoliday per holiday, then one createClinicRequiredDocument per doc).
//
// Schema (matches the Clinic entity in
// FortyTwoApps/helix-engage/src/objects/clinic.object.ts, column names
// derived from SDK labels — that's why opensAt/closesAt and not openTime/
// closeTime):
// - clinicName (TEXT)
// - address (ADDRESS → addressCustomAddress*)
// - phone (PHONES)
// - email (EMAILS)
// - openMonday..openSunday (7 BOOLEANs)
// - opensAt / closesAt (TEXT, HH:MM)
// - status (SELECT enum)
// - walkInAllowed / onlineBooking (BOOLEAN)
// - cancellationWindowHours / arriveEarlyMin (NUMBER)
//
// Plus two child entities populated separately:
// - Holiday (one record per closure date)
// - ClinicRequiredDocument (one record per required doc type)
export type ClinicStatus = 'ACTIVE' | 'TEMPORARILY_CLOSED' | 'PERMANENTLY_CLOSED';
// Matches the SELECT enum on ClinicRequiredDocument. Keep in sync with
// FortyTwoApps/helix-engage/src/objects/clinic-required-document.object.ts.
export type DocumentType =
| 'ID_PROOF'
| 'AADHAAR'
| 'PAN'
| 'REFERRAL_LETTER'
| 'PRESCRIPTION'
| 'INSURANCE_CARD'
| 'PREVIOUS_REPORTS'
| 'PHOTO'
| 'OTHER';
const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
ID_PROOF: 'Government ID',
AADHAAR: 'Aadhaar Card',
PAN: 'PAN Card',
REFERRAL_LETTER: 'Referral Letter',
PRESCRIPTION: 'Prescription',
INSURANCE_CARD: 'Insurance Card',
PREVIOUS_REPORTS: 'Previous Reports',
PHOTO: 'Passport Photo',
OTHER: 'Other',
};
const DOCUMENT_TYPE_ORDER: DocumentType[] = [
'ID_PROOF',
'AADHAAR',
'PAN',
'REFERRAL_LETTER',
'PRESCRIPTION',
'INSURANCE_CARD',
'PREVIOUS_REPORTS',
'PHOTO',
'OTHER',
];
export type ClinicHolidayEntry = {
// Populated on the existing record when editing; undefined for freshly
// added holidays the user hasn't saved yet. Used by the parent to
// decide create vs update vs delete on save.
id?: string;
date: string; // ISO yyyy-MM-dd
label: string;
};
export type ClinicFormValues = {
// Core clinic fields
clinicName: string;
addressStreet1: string;
addressStreet2: string;
addressCity: string;
addressState: string;
addressPostcode: string;
phone: string;
email: string;
// Schedule — simple pattern
openDays: DaySelection;
opensAt: string | null;
closesAt: string | null;
// Status + booking policy
status: ClinicStatus;
walkInAllowed: boolean;
onlineBooking: boolean;
cancellationWindowHours: string;
arriveEarlyMin: string;
// Children (persisted via separate mutations)
requiredDocumentTypes: DocumentType[];
holidays: ClinicHolidayEntry[];
};
export const emptyClinicFormValues = (): ClinicFormValues => ({
clinicName: '',
addressStreet1: '',
addressStreet2: '',
addressCity: '',
addressState: '',
addressPostcode: '',
phone: '',
email: '',
openDays: defaultDaySelection(),
opensAt: '09:00',
closesAt: '18:00',
status: 'ACTIVE',
walkInAllowed: true,
onlineBooking: true,
cancellationWindowHours: '24',
arriveEarlyMin: '15',
requiredDocumentTypes: [],
holidays: [],
});
const STATUS_ITEMS = [
{ id: 'ACTIVE', label: 'Active' },
{ id: 'TEMPORARILY_CLOSED', label: 'Temporarily closed' },
{ id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' },
];
// Build the payload for `createClinic` / `updateClinic`. Holidays and
// required-documents are NOT included here — they're child records with
// their own mutations, orchestrated by the parent component after the
// clinic itself has been created and its id is known.
export const clinicCoreToGraphQLInput = (v: ClinicFormValues): Record<string, unknown> => {
const input: Record<string, unknown> = {
clinicName: v.clinicName.trim(),
status: v.status,
walkInAllowed: v.walkInAllowed,
onlineBooking: v.onlineBooking,
openMonday: v.openDays.monday,
openTuesday: v.openDays.tuesday,
openWednesday: v.openDays.wednesday,
openThursday: v.openDays.thursday,
openFriday: v.openDays.friday,
openSaturday: v.openDays.saturday,
openSunday: v.openDays.sunday,
};
// Column names on the platform come from the SDK `label`, not
// `name`. "Opens At" → opensAt, "Closes At" → closesAt.
if (v.opensAt) input.opensAt = v.opensAt;
if (v.closesAt) input.closesAt = v.closesAt;
const hasAddress =
v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode;
if (hasAddress) {
input.addressCustom = {
addressStreet1: v.addressStreet1 || null,
addressStreet2: v.addressStreet2 || null,
addressCity: v.addressCity || null,
addressState: v.addressState || null,
addressPostcode: v.addressPostcode || null,
addressCountry: 'India',
};
}
if (v.phone.trim()) {
input.phone = {
primaryPhoneNumber: v.phone.trim(),
primaryPhoneCountryCode: 'IN',
primaryPhoneCallingCode: '+91',
additionalPhones: null,
};
}
if (v.email.trim()) {
input.email = {
primaryEmail: v.email.trim(),
additionalEmails: null,
};
}
if (v.cancellationWindowHours.trim()) {
const n = Number(v.cancellationWindowHours);
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
}
if (v.arriveEarlyMin.trim()) {
const n = Number(v.arriveEarlyMin);
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
}
return input;
};
// Helper: build HolidayCreateInput payloads. Use after the clinic has
// been created and its id is known.
export const holidayInputsFromForm = (
v: ClinicFormValues,
clinicId: string,
): Array<Record<string, unknown>> =>
v.holidays.map((h) => ({
date: h.date,
reasonLabel: h.label.trim() || null, // column name matches the SDK label "Reason / Label"
clinicId,
}));
// Helper: build ClinicRequiredDocumentCreateInput payloads. One per
// selected document type.
export const requiredDocInputsFromForm = (
v: ClinicFormValues,
clinicId: string,
): Array<Record<string, unknown>> =>
v.requiredDocumentTypes.map((t) => ({
documentType: t,
clinicId,
}));
type ClinicFormProps = {
value: ClinicFormValues;
onChange: (value: ClinicFormValues) => void;
};
export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
const patch = (updates: Partial<ClinicFormValues>) => onChange({ ...value, ...updates });
// Required-docs add/remove handlers. The user picks a type from the
// dropdown; it gets added to the list; the pill row below shows
// selected types with an X to remove. Dropdown filters out
// already-selected types so the user can't pick duplicates.
const availableDocTypes = DOCUMENT_TYPE_ORDER.filter(
(t) => !value.requiredDocumentTypes.includes(t),
).map((t) => ({ id: t, label: DOCUMENT_TYPE_LABELS[t] }));
const addDocType = (type: DocumentType) => {
if (value.requiredDocumentTypes.includes(type)) return;
patch({ requiredDocumentTypes: [...value.requiredDocumentTypes, type] });
};
const removeDocType = (type: DocumentType) => {
patch({
requiredDocumentTypes: value.requiredDocumentTypes.filter((t) => t !== type),
});
};
// Holiday add/remove handlers. Freshly-added entries have no `id`
// field; the parent's save flow treats those as "create".
const addHoliday = () => {
const todayIso = today(getLocalTimeZone()).toString();
patch({ holidays: [...value.holidays, { date: todayIso, label: '' }] });
};
const updateHoliday = (index: number, updates: Partial<ClinicHolidayEntry>) => {
const next = [...value.holidays];
next[index] = { ...next[index], ...updates };
patch({ holidays: next });
};
const removeHoliday = (index: number) => {
patch({ holidays: value.holidays.filter((_, i) => i !== index) });
};
return (
<div className="flex flex-col gap-4">
<Input
label="Clinic name"
isRequired
placeholder="e.g. Main Hospital Campus"
value={value.clinicName}
onChange={(v) => patch({ clinicName: v })}
/>
<Select
label="Status"
placeholder="Select status"
items={STATUS_ITEMS}
selectedKey={value.status}
onSelectionChange={(key) => patch({ status: key as ClinicStatus })}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{/* Address */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Address
</p>
<Input
label="Street address"
placeholder="Street / building / landmark"
value={value.addressStreet1}
onChange={(v) => patch({ addressStreet1: v })}
/>
<Input
label="Area / locality (optional)"
placeholder="Area, neighbourhood"
value={value.addressStreet2}
onChange={(v) => patch({ addressStreet2: v })}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="City"
placeholder="Bengaluru"
value={value.addressCity}
onChange={(v) => patch({ addressCity: v })}
/>
<Input
label="State"
placeholder="Karnataka"
value={value.addressState}
onChange={(v) => patch({ addressState: v })}
/>
</div>
<Input
label="Postcode"
placeholder="560034"
value={value.addressPostcode}
onChange={(v) => patch({ addressPostcode: v })}
/>
</div>
{/* Contact */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Contact
</p>
<Input
label="Phone"
type="tel"
placeholder="9876543210"
value={value.phone}
onChange={(v) => patch({ phone: v })}
/>
<Input
label="Email"
type="email"
placeholder="branch@hospital.com"
value={value.email}
onChange={(v) => patch({ email: v })}
/>
</div>
{/* Visiting hours — day pills + single time range */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Visiting hours
</p>
<DaySelector
label="Open days"
hint="Pick the days this clinic is open. The time range below applies to every selected day."
value={value.openDays}
onChange={(openDays) => patch({ openDays })}
/>
<div className="grid grid-cols-2 gap-3">
<TimePicker
label="Opens at"
value={value.opensAt}
onChange={(opensAt) => patch({ opensAt })}
/>
<TimePicker
label="Closes at"
value={value.closesAt}
onChange={(closesAt) => patch({ closesAt })}
/>
</div>
</div>
{/* Holiday closures */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Holiday closures (optional)
</p>
{value.holidays.length === 0 && (
<p className="text-xs text-tertiary">
No holidays configured. Add dates when this clinic is closed (Diwali,
Republic Day, maintenance days, etc.).
</p>
)}
{value.holidays.map((h, idx) => (
<div
key={idx}
className="flex items-end gap-2 rounded-lg border border-secondary bg-secondary p-3"
>
<div className="shrink-0">
<span className="mb-1 block text-xs font-medium text-secondary">
Date
</span>
<DatePicker
value={h.date ? parseDate(h.date) : null}
onChange={(dv: DateValue | null) =>
updateHoliday(idx, { date: dv ? dv.toString() : '' })
}
// Holidays must be today or in the future — you
// can't observe a holiday that already passed.
minValue={today(getLocalTimeZone())}
/>
</div>
<div className="flex-1">
<Input
label="Reason"
placeholder="e.g. Diwali"
value={h.label}
onChange={(label) => updateHoliday(idx, { label })}
/>
</div>
<Button
size="sm"
color="tertiary-destructive"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
)}
onClick={() => removeHoliday(idx)}
>
Remove
</Button>
</div>
))}
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={addHoliday}
className="self-start"
>
Add holiday
</Button>
</div>
{/* Booking policy */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Booking policy
</p>
<div className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Walk-ins allowed"
isSelected={value.walkInAllowed}
onChange={(checked) => patch({ walkInAllowed: checked })}
/>
<Toggle
label="Accept online bookings"
isSelected={value.onlineBooking}
onChange={(checked) => patch({ onlineBooking: checked })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Cancel window (hours)"
type="number"
value={value.cancellationWindowHours}
onChange={(v) => patch({ cancellationWindowHours: v })}
/>
<Input
label="Arrive early (min)"
type="number"
value={value.arriveEarlyMin}
onChange={(v) => patch({ arriveEarlyMin: v })}
/>
</div>
</div>
{/* Required documents — multi-select → pills */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Required documents (optional)
</p>
{availableDocTypes.length > 0 && (
<Select
label="Add a required document"
placeholder="Pick a document type..."
items={availableDocTypes}
selectedKey={null}
onSelectionChange={(key) => {
if (key) addDocType(key as DocumentType);
}}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
)}
{value.requiredDocumentTypes.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.requiredDocumentTypes.map((t) => (
<button
key={t}
type="button"
onClick={() => removeDocType(t)}
className="group flex items-center gap-2 rounded-full border border-brand bg-brand-secondary px-3 py-1.5 text-sm font-medium text-brand-secondary transition hover:bg-brand-primary_hover"
>
{DOCUMENT_TYPE_LABELS[t]}
<FontAwesomeIcon
icon={faTrash}
className="size-3 text-fg-quaternary group-hover:text-fg-error-primary"
/>
</button>
))}
</div>
)}
{value.requiredDocumentTypes.length === 0 && (
<p className="text-xs text-tertiary">
No required documents. Patients won't be asked to bring anything.
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,401 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { Toggle } from '@/components/base/toggle/toggle';
import { Button } from '@/components/base/buttons/button';
import { TimePicker } from '@/components/application/date-picker/time-picker';
// Doctor form — hospital-wide profile with multi-clinic, multi-day
// visiting schedule. Each row in the "visiting schedule" section maps
// to one DoctorVisitSlot child record. The parent component owns the
// mutation orchestration (create doctor, then create each slot).
//
// Previously the form had a single `clinicId` dropdown + a free-text
// `visitingHours` textarea. Both dropped — doctors are now hospital-
// wide, and their presence at each clinic is expressed via the
// DoctorVisitSlot records.
export type DoctorDepartment =
| 'CARDIOLOGY'
| 'GYNECOLOGY'
| 'ORTHOPEDICS'
| 'GENERAL_MEDICINE'
| 'ENT'
| 'DERMATOLOGY'
| 'PEDIATRICS'
| 'ONCOLOGY';
// Matches the DoctorVisitSlot.dayOfWeek SELECT enum on the SDK entity.
export type DayOfWeek =
| 'MONDAY'
| 'TUESDAY'
| 'WEDNESDAY'
| 'THURSDAY'
| 'FRIDAY'
| 'SATURDAY'
| 'SUNDAY';
export type DoctorVisitSlotEntry = {
// Populated on existing records when editing; undefined for
// freshly-added rows. Used by the parent to decide create vs
// update vs delete on save.
id?: string;
clinicId: string;
dayOfWeek: DayOfWeek | '';
startTime: string | null;
endTime: string | null;
};
export type DoctorFormValues = {
firstName: string;
lastName: string;
department: DoctorDepartment | '';
specialty: string;
qualifications: string;
yearsOfExperience: string;
consultationFeeNew: string;
consultationFeeFollowUp: string;
phone: string;
email: string;
registrationNumber: string;
active: boolean;
// Multi-clinic, multi-day visiting schedule. One entry per slot.
visitSlots: DoctorVisitSlotEntry[];
};
export const emptyDoctorFormValues = (): DoctorFormValues => ({
firstName: '',
lastName: '',
department: '',
specialty: '',
qualifications: '',
yearsOfExperience: '',
consultationFeeNew: '',
consultationFeeFollowUp: '',
phone: '',
email: '',
registrationNumber: '',
active: true,
visitSlots: [],
});
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
{ id: 'CARDIOLOGY', label: 'Cardiology' },
{ id: 'GYNECOLOGY', label: 'Gynecology' },
{ id: 'ORTHOPEDICS', label: 'Orthopedics' },
{ id: 'GENERAL_MEDICINE', label: 'General medicine' },
{ id: 'ENT', label: 'ENT' },
{ id: 'DERMATOLOGY', label: 'Dermatology' },
{ id: 'PEDIATRICS', label: 'Pediatrics' },
{ id: 'ONCOLOGY', label: 'Oncology' },
];
const DAY_ITEMS: { id: DayOfWeek; label: string }[] = [
{ id: 'MONDAY', label: 'Monday' },
{ id: 'TUESDAY', label: 'Tuesday' },
{ id: 'WEDNESDAY', label: 'Wednesday' },
{ id: 'THURSDAY', label: 'Thursday' },
{ id: 'FRIDAY', label: 'Friday' },
{ id: 'SATURDAY', label: 'Saturday' },
{ id: 'SUNDAY', label: 'Sunday' },
];
// Build the createDoctor / updateDoctor mutation payload. Visit slots
// are persisted via a separate mutation chain — see the parent
// component's handleSave.
export const doctorCoreToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
const input: Record<string, unknown> = {
fullName: {
firstName: v.firstName.trim(),
lastName: v.lastName.trim(),
},
active: v.active,
};
if (v.department) input.department = v.department;
if (v.specialty.trim()) input.specialty = v.specialty.trim();
if (v.qualifications.trim()) input.qualifications = v.qualifications.trim();
if (v.yearsOfExperience.trim()) {
const n = Number(v.yearsOfExperience);
if (!Number.isNaN(n)) input.yearsOfExperience = n;
}
if (v.consultationFeeNew.trim()) {
const n = Number(v.consultationFeeNew);
if (!Number.isNaN(n)) {
input.consultationFeeNew = {
amountMicros: Math.round(n * 1_000_000),
currencyCode: 'INR',
};
}
}
if (v.consultationFeeFollowUp.trim()) {
const n = Number(v.consultationFeeFollowUp);
if (!Number.isNaN(n)) {
input.consultationFeeFollowUp = {
amountMicros: Math.round(n * 1_000_000),
currencyCode: 'INR',
};
}
}
if (v.phone.trim()) {
input.phone = {
primaryPhoneNumber: v.phone.trim(),
primaryPhoneCountryCode: 'IN',
primaryPhoneCallingCode: '+91',
additionalPhones: null,
};
}
if (v.email.trim()) {
input.email = {
primaryEmail: v.email.trim(),
additionalEmails: null,
};
}
if (v.registrationNumber.trim()) input.registrationNumber = v.registrationNumber.trim();
return input;
};
// Build one DoctorVisitSlotCreateInput per complete slot. Drops any
// half-filled rows silently — the form can't validate mid-entry
// without blocking the user.
export const visitSlotInputsFromForm = (
v: DoctorFormValues,
doctorId: string,
): Array<Record<string, unknown>> =>
v.visitSlots
.filter((s) => s.clinicId && s.dayOfWeek && s.startTime && s.endTime)
.map((s) => ({
doctorId,
clinicId: s.clinicId,
dayOfWeek: s.dayOfWeek,
startTime: s.startTime,
endTime: s.endTime,
}));
type ClinicOption = { id: string; label: string };
type DoctorFormProps = {
value: DoctorFormValues;
onChange: (value: DoctorFormValues) => void;
clinics: ClinicOption[];
};
export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
const patch = (updates: Partial<DoctorFormValues>) => onChange({ ...value, ...updates });
// Visit-slot handlers — add/edit/remove inline inside the form.
const addSlot = () => {
patch({
visitSlots: [
...value.visitSlots,
{ clinicId: clinics[0]?.id ?? '', dayOfWeek: '', startTime: '09:00', endTime: '13:00' },
],
});
};
const updateSlot = (index: number, updates: Partial<DoctorVisitSlotEntry>) => {
const next = [...value.visitSlots];
next[index] = { ...next[index], ...updates };
patch({ visitSlots: next });
};
const removeSlot = (index: number) => {
patch({ visitSlots: value.visitSlots.filter((_, i) => i !== index) });
};
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="First name"
isRequired
placeholder="Ananya"
value={value.firstName}
onChange={(v) => patch({ firstName: v })}
/>
<Input
label="Last name"
isRequired
placeholder="Rao"
value={value.lastName}
onChange={(v) => patch({ lastName: v })}
/>
</div>
<Select
label="Department"
placeholder="Select department"
items={DEPARTMENT_ITEMS}
selectedKey={value.department || null}
onSelectionChange={(key) => patch({ department: (key as DoctorDepartment) || '' })}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Input
label="Specialty"
placeholder="e.g. Interventional cardiology"
value={value.specialty}
onChange={(v) => patch({ specialty: v })}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label="Qualifications"
placeholder="MBBS, MD"
value={value.qualifications}
onChange={(v) => patch({ qualifications: v })}
/>
<Input
label="Experience (years)"
type="number"
value={value.yearsOfExperience}
onChange={(v) => patch({ yearsOfExperience: v })}
/>
</div>
{/* Visiting schedule — one row per clinic/day slot */}
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Visiting schedule
</p>
{clinics.length === 0 ? (
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
<p className="mt-1 text-xs text-tertiary">
You need at least one clinic before you can schedule doctor visits.
</p>
</div>
) : (
<>
{value.visitSlots.length === 0 && (
<p className="text-xs text-tertiary">
No visit slots. Add rows for each clinic + day this doctor visits.
</p>
)}
{value.visitSlots.map((slot, idx) => (
<div
key={idx}
className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-3"
>
<div className="grid grid-cols-2 gap-3">
<Select
label="Clinic"
placeholder="Select clinic"
items={clinics}
selectedKey={slot.clinicId || null}
onSelectionChange={(key) =>
updateSlot(idx, { clinicId: (key as string) || '' })
}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select
label="Day"
placeholder="Select day"
items={DAY_ITEMS}
selectedKey={slot.dayOfWeek || null}
onSelectionChange={(key) =>
updateSlot(idx, { dayOfWeek: (key as DayOfWeek) || '' })
}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<TimePicker
label="Start time"
value={slot.startTime}
onChange={(startTime) => updateSlot(idx, { startTime })}
/>
<TimePicker
label="End time"
value={slot.endTime}
onChange={(endTime) => updateSlot(idx, { endTime })}
/>
</div>
<div className="flex justify-end">
<Button
size="sm"
color="tertiary-destructive"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
)}
onClick={() => removeSlot(idx)}
>
Remove slot
</Button>
</div>
</div>
))}
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={addSlot}
className="self-start"
>
Add visit slot
</Button>
</>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="New consult fee (₹)"
type="number"
placeholder="800"
value={value.consultationFeeNew}
onChange={(v) => patch({ consultationFeeNew: v })}
/>
<Input
label="Follow-up fee (₹)"
type="number"
placeholder="500"
value={value.consultationFeeFollowUp}
onChange={(v) => patch({ consultationFeeFollowUp: v })}
/>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
<Input
label="Phone"
type="tel"
placeholder="9876543210"
value={value.phone}
onChange={(v) => patch({ phone: v })}
/>
<Input
label="Email"
type="email"
placeholder="doctor@hospital.com"
value={value.email}
onChange={(v) => patch({ email: v })}
/>
<Input
label="Registration number"
placeholder="Medical council reg no."
value={value.registrationNumber}
onChange={(v) => patch({ registrationNumber: v })}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Accepting appointments"
isSelected={value.active}
onChange={(checked) => patch({ active: checked })}
/>
<p className="text-xs text-tertiary">
Inactive doctors are hidden from appointment booking and call-desk transfer lists.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,205 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash, faRotate } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
// In-place employee creation form used by the Team wizard step and
// the /settings/team slideout. Replaces the multi-email InviteMemberForm
// — this project never uses email invitations, all employees are
// created directly with a temp password that the admin hands out.
//
// Two modes:
//
// - 'create': all fields editable. The temp password is auto-generated
// on form mount (parent does this) and revealed via an eye icon. A
// refresh icon next to the eye lets the admin re-roll the password
// before saving.
//
// - 'edit': email is read-only (it's the login id, can't change),
// password field is hidden entirely (no reset-password from the
// wizard). Only firstName/lastName/role can change.
//
// SIP seat assignment is intentionally NOT in this form — it lives
// exclusively in the Telephony wizard step, so there's a single source
// of truth for "who is on which seat" and admins don't have to remember
// two places to manage the same thing.
export type RoleOption = {
id: string;
label: string;
supportingText?: string;
};
export type EmployeeCreateFormValues = {
firstName: string;
lastName: string;
email: string;
password: string;
roleId: string;
};
export const emptyEmployeeCreateFormValues: EmployeeCreateFormValues = {
firstName: '',
lastName: '',
email: '',
password: '',
roleId: '',
};
// Random temp password generator. Skips visually-ambiguous chars
// (0/O/1/l/I) so admins can read the password back over a phone call
// without typo risk. 11 alphanumerics + 1 symbol = 12 chars total.
export const generateTempPassword = (): string => {
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const symbols = '!@#$';
let pwd = '';
for (let i = 0; i < 11; i++) {
pwd += chars[Math.floor(Math.random() * chars.length)];
}
pwd += symbols[Math.floor(Math.random() * symbols.length)];
return pwd;
};
type EmployeeCreateFormProps = {
value: EmployeeCreateFormValues;
onChange: (value: EmployeeCreateFormValues) => void;
roles: RoleOption[];
// 'create' = full form, 'edit' = name + role only.
mode?: 'create' | 'edit';
};
// Eye / eye-slash button rendered inside the password field's
// trailing slot. Stays internal to this form since password reveal
// is the only place we need it right now.
const EyeButton = ({ visible, onClick, title }: { visible: boolean; onClick: () => void; title: string }) => (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
>
<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} className="size-4" />
</button>
);
const RegenerateButton = ({ onClick }: { onClick: () => void }) => (
<button
type="button"
onClick={onClick}
title="Generate a new password"
aria-label="Generate a new password"
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
>
<FontAwesomeIcon icon={faRotate} className="size-4" />
</button>
);
// Kept simple — name + contact + creds + role. No avatar, no phone,
// no title. The goal is to get employees onto the system fast; they
// can fill in the rest from their own profile page later.
export const EmployeeCreateForm = ({
value,
onChange,
roles,
mode = 'create',
}: EmployeeCreateFormProps) => {
const [showPassword, setShowPassword] = useState(false);
const patch = (partial: Partial<EmployeeCreateFormValues>) =>
onChange({ ...value, ...partial });
const isEdit = mode === 'edit';
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<Input
label="First name"
placeholder="Priya"
value={value.firstName}
onChange={(v) => patch({ firstName: v })}
isRequired
/>
<Input
label="Last name"
placeholder="Sharma"
value={value.lastName}
onChange={(v) => patch({ lastName: v })}
/>
</div>
<Input
label="Email"
type="email"
placeholder="priya@hospital.com"
value={value.email}
onChange={(v) => patch({ email: v })}
isRequired={!isEdit}
isReadOnly={isEdit}
isDisabled={isEdit}
hint={
isEdit
? 'Email is the login id and cannot be changed.'
: 'This is the login id for the employee. Cannot be changed later.'
}
/>
{!isEdit && (
<div>
<label className="block text-sm font-medium text-secondary">
Temporary password <span className="text-error-primary">*</span>
</label>
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 shadow-xs focus-within:border-brand focus-within:ring-2 focus-within:ring-brand-100">
<input
type={showPassword ? 'text' : 'password'}
value={value.password}
onChange={(e) => patch({ password: e.target.value })}
placeholder="Auto-generated"
className="flex-1 bg-transparent py-2 font-mono text-sm text-primary placeholder:text-placeholder outline-none"
/>
<EyeButton
visible={showPassword}
onClick={() => setShowPassword((v) => !v)}
title={showPassword ? 'Hide password' : 'Show password'}
/>
<RegenerateButton
onClick={() => {
patch({ password: generateTempPassword() });
setShowPassword(true);
}}
/>
</div>
<p className="mt-1 text-xs text-tertiary">
Auto-generated. Click the refresh icon to roll a new one. Share with the
employee directly they should change it after first login.
</p>
</div>
)}
<Select
label="Role"
placeholder={roles.length === 0 ? 'No roles available' : 'Select a role'}
isDisabled={roles.length === 0}
items={roles}
selectedKey={value.roleId || null}
onSelectionChange={(key) => patch({ roleId: (key as string) || '' })}
isRequired
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-3 text-xs text-tertiary">
SIP seats are managed in the <b>Telephony</b> step create the employee here
first, then assign them a seat there.
</div>
</div>
);
};

View File

@@ -0,0 +1,202 @@
import { Input } from '@/components/base/input/input';
// Telephony form — covers Ozonetel cloud-call-center, the Ozonetel WebRTC
// gateway, and Exotel REST API credentials. Mirrors the TelephonyConfig shape
// in helix-engage-server/src/config/telephony.defaults.ts.
//
// Secrets (ozonetel.agentPassword, exotel.apiToken) come back from the GET
// endpoint as the sentinel '***masked***' — the form preserves that sentinel
// untouched unless the admin actually edits the field, in which case the
// backend overwrites the stored value. This is the same convention used by
// TelephonyConfigService.getMaskedConfig / updateConfig.
export type TelephonyFormValues = {
ozonetel: {
agentId: string;
agentPassword: string;
did: string;
sipId: string;
campaignName: string;
adminUsername: string;
adminPassword: string;
};
sip: {
domain: string;
wsPort: string;
};
exotel: {
apiKey: string;
apiToken: string;
accountSid: string;
subdomain: string;
};
};
export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
ozonetel: {
agentId: '',
agentPassword: '',
did: '',
sipId: '',
campaignName: '',
adminUsername: '',
adminPassword: '',
},
sip: {
domain: 'blr-pub-rtc4.ozonetel.com',
wsPort: '444',
},
exotel: {
apiKey: '',
apiToken: '',
accountSid: '',
subdomain: 'api.exotel.com',
},
});
type TelephonyFormProps = {
value: TelephonyFormValues;
onChange: (value: TelephonyFormValues) => void;
};
export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
const patchOzonetel = (updates: Partial<TelephonyFormValues['ozonetel']>) =>
onChange({ ...value, ozonetel: { ...value.ozonetel, ...updates } });
const patchSip = (updates: Partial<TelephonyFormValues['sip']>) =>
onChange({ ...value, sip: { ...value.sip, ...updates } });
const patchExotel = (updates: Partial<TelephonyFormValues['exotel']>) =>
onChange({ ...value, exotel: { ...value.exotel, ...updates } });
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Ozonetel Cloud Agent</h3>
<p className="mt-1 text-xs text-tertiary">
Outbound dialing, SIP registration, and agent provisioning. Get these values from your
Ozonetel dashboard under Admin Users and Numbers.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Agent ID"
placeholder="e.g. agent001"
value={value.ozonetel.agentId}
onChange={(v) => patchOzonetel({ agentId: v })}
/>
<Input
label="Agent password"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.ozonetel.agentPassword}
onChange={(v) => patchOzonetel({ agentPassword: v })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Default DID"
placeholder="Primary hospital number"
value={value.ozonetel.did}
onChange={(v) => patchOzonetel({ did: v })}
/>
<Input
label="SIP ID"
placeholder="Softphone extension"
value={value.ozonetel.sipId}
onChange={(v) => patchOzonetel({ sipId: v })}
/>
</div>
<Input
label="Campaign name"
placeholder="CloudAgent campaign for outbound dial"
value={value.ozonetel.campaignName}
onChange={(v) => patchOzonetel({ campaignName: v })}
/>
<div>
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
<p className="mt-0.5 text-xs text-tertiary">
Ozonetel portal admin credentials required for supervisor barge/whisper/listen.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Admin username"
placeholder="Ozonetel portal admin login"
value={value.ozonetel.adminUsername}
onChange={(v) => patchOzonetel({ adminUsername: v })}
/>
<Input
label="Admin password"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.ozonetel.adminPassword}
onChange={(v) => patchOzonetel({ adminPassword: v })}
/>
</div>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">SIP Gateway (WebRTC)</h3>
<p className="mt-1 text-xs text-tertiary">
Used by the staff portal softphone. Defaults work for most Indian Ozonetel tenants only
change if Ozonetel support instructs you to.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="SIP domain"
placeholder="blr-pub-rtc4.ozonetel.com"
value={value.sip.domain}
onChange={(v) => patchSip({ domain: v })}
/>
<Input
label="WebSocket port"
placeholder="444"
value={value.sip.wsPort}
onChange={(v) => patchSip({ wsPort: v })}
/>
</div>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Exotel (SMS + inbound numbers)</h3>
<p className="mt-1 text-xs text-tertiary">
Optional only required if you use Exotel for SMS or want inbound number management from
this portal.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="API key"
placeholder="Exotel API key"
value={value.exotel.apiKey}
onChange={(v) => patchExotel({ apiKey: v })}
/>
<Input
label="API token"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.exotel.apiToken}
onChange={(v) => patchExotel({ apiToken: v })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Account SID"
placeholder="Exotel account SID"
value={value.exotel.accountSid}
onChange={(v) => patchExotel({ accountSid: v })}
/>
<Input
label="Subdomain"
placeholder="api.exotel.com"
value={value.exotel.subdomain}
onChange={(v) => patchExotel({ subdomain: v })}
/>
</div>
</section>
</div>
);
};

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Toggle } from '@/components/base/toggle/toggle';
import { Button } from '@/components/base/buttons/button';
// Widget form — mirrors WidgetConfig from
// helix-engage-server/src/config/widget.defaults.ts. The site key and site ID
// are read-only (generated / rotated by the backend), the rest are editable.
//
// allowedOrigins is an origin allowlist — an empty list means "any origin"
// which is useful for testing but should be tightened in production.
export type WidgetFormValues = {
enabled: boolean;
url: string;
allowedOrigins: string[];
embed: {
loginPage: boolean;
};
};
export const emptyWidgetFormValues = (): WidgetFormValues => ({
enabled: true,
url: '',
allowedOrigins: [],
embed: {
loginPage: false,
},
});
type WidgetFormProps = {
value: WidgetFormValues;
onChange: (value: WidgetFormValues) => void;
};
export const WidgetForm = ({ value, onChange }: WidgetFormProps) => {
const [originDraft, setOriginDraft] = useState('');
const addOrigin = () => {
const trimmed = originDraft.trim();
if (!trimmed) return;
if (value.allowedOrigins.includes(trimmed)) {
setOriginDraft('');
return;
}
onChange({ ...value, allowedOrigins: [...value.allowedOrigins, trimmed] });
setOriginDraft('');
};
const removeOrigin = (origin: string) => {
onChange({ ...value, allowedOrigins: value.allowedOrigins.filter((o) => o !== origin) });
};
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Activation</h3>
<p className="mt-1 text-xs text-tertiary">
When disabled, widget.js returns an empty response and the script no-ops on the
embedding page. Use this as a kill switch if something goes wrong in production.
</p>
</div>
<div className="rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Website widget enabled"
isSelected={value.enabled}
onChange={(checked) => onChange({ ...value, enabled: checked })}
/>
</div>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Hosting</h3>
<p className="mt-1 text-xs text-tertiary">
Public base URL where widget.js is served from. Leave blank to use the same origin as
this sidecar (the common case).
</p>
</div>
<Input
label="Public URL"
placeholder="https://widget.hospital.com"
value={value.url}
onChange={(v) => onChange({ ...value, url: v })}
/>
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Allowed origins</h3>
<p className="mt-1 text-xs text-tertiary">
Origins where the widget may be embedded. An empty list means any origin is accepted
(test mode). In production, list every hospital website + staging environment
explicitly.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder="https://hospital.com"
value={originDraft}
onChange={setOriginDraft}
/>
</div>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={addOrigin}
isDisabled={!originDraft.trim()}
>
Add
</Button>
</div>
{value.allowedOrigins.length === 0 ? (
<p className="rounded-lg border border-dashed border-secondary bg-secondary p-4 text-center text-xs text-tertiary">
Any origin allowed widget runs in test mode.
</p>
) : (
<ul className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
{value.allowedOrigins.map((origin) => (
<li
key={origin}
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
>
<span className="font-mono text-primary">{origin}</span>
<button
type="button"
onClick={() => removeOrigin(origin)}
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
title="Remove origin"
>
<FontAwesomeIcon icon={faTrash} className="size-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-4">
<div>
<h3 className="text-sm font-semibold text-primary">Embed surfaces</h3>
<p className="mt-1 text-xs text-tertiary">
Where inside this application the widget should auto-render. Keep these off if you
only plan to embed it on your public hospital website.
</p>
</div>
<div className="rounded-lg border border-secondary bg-secondary p-4">
<Toggle
label="Show on staff login page"
hint="Useful for smoke-testing without a public landing page."
isSelected={value.embed.loginPage}
onChange={(checked) =>
onChange({ ...value, embed: { ...value.embed, loginPage: checked } })
}
/>
</div>
</section>
</div>
);
};

View File

@@ -8,12 +8,14 @@ import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget'; import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell'; import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status'; import { useNetworkStatus } from '@/hooks/use-network-status';
// import { GlobalSearch } from '@/components/shared/global-search';
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -92,23 +94,6 @@ export const AppShell = ({ children }: AppShellProps) => {
</div> </div>
) : undefined; ) : undefined;
// Load external script for all authenticated users
useEffect(() => {
// Expose API URL to external script
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
(window as any).HELIX_API_URL = apiUrl;
const script = document.createElement('script');
script.src = `https://cdn.jsdelivr.net/gh/moulichand16/Test@d0a79d0/script.js`;
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
delete (window as any).HELIX_API_URL;
};
}, []);
// Heartbeat: keep agent session alive in Redis (CC agents only) // Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => { useEffect(() => {
if (!isCCAgent) return; if (!isCCAgent) return;
@@ -133,34 +118,33 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex h-screen bg-primary"> <div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} /> <Sidebar activeUrl={pathname} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */} {/* Agent top bar — network indicator + status toggle (agents only) */}
{(hasAgentConfig || isAdmin) && ( {hasAgentConfig && (
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
{isAdmin && <NotificationBell />} <div className="ml-auto flex items-center gap-2">
{hasAgentConfig && ( <div className={cx(
<> 'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
<div className={cx( networkQuality === 'good'
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium', ? 'bg-success-primary text-success-primary'
networkQuality === 'good' : networkQuality === 'offline'
? 'bg-success-primary text-success-primary' ? 'bg-error-secondary text-error-primary'
: networkQuality === 'offline' : 'bg-warning-secondary text-warning-primary',
? 'bg-error-secondary text-error-primary' )}>
: 'bg-warning-secondary text-warning-primary', <FontAwesomeIcon
)}> icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
<FontAwesomeIcon className="size-3"
icon={networkQuality === 'offline' ? faWifiSlash : faWifi} />
className="size-3" {networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
/> </div>
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'} <AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</div> </div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
</div> </div>
)} )}
<ResumeSetupBanner />
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div> </div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />} {isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
{isAdmin && !isCCAgent && <AiFloatingButton />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -2,46 +2,14 @@ import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons'; import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts'; import { usePerformanceAlerts } from '@/hooks/use-performance-alerts';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const DEMO_ALERTS: PerformanceAlert[] = [
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
];
export const NotificationBell = () => { export const NotificationBell = () => {
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts(); const { alerts, dismiss, dismissAll } = usePerformanceAlerts();
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS); const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
// Use live alerts if available, otherwise demo
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
const isDemo = liveAlerts.length === 0;
const dismiss = (id: string) => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
} else {
liveDismiss(id);
}
};
const dismissAll = () => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
} else {
liveDismissAll();
}
};
// Close on outside click // Close on outside click
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -123,7 +91,7 @@ export const NotificationBell = () => {
<p className="text-sm font-medium text-primary">{alert.agent}</p> <p className="text-sm font-medium text-primary">{alert.agent}</p>
<p className="text-xs text-tertiary">{alert.type}</p> <p className="text-xs text-tertiary">{alert.type}</p>
</div> </div>
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge> <Badge size="sm" color={alert.severity === 'error' ? 'error' : alert.severity === 'warning' ? 'warning' : 'gray'} type="pill-color">{alert.value}</Badge>
<button <button
onClick={() => dismiss(alert.id)} onClick={() => dismiss(alert.id)}
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear" className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"

View File

@@ -0,0 +1,102 @@
// PageHeader — consistent header layout for all list pages.
//
// Row 1: Title (+ optional badge + info icon) on the left,
// controls (search, columns, export, etc.) on the right.
// Row 2: Optional tabs (underline style) — no extra borders.
//
// The `infoText` prop renders as a hoverable info icon (ⓘ) next to
// the title. Long descriptive text goes here instead of inline
// subtitle — keeps the header compact.
//
// Usage:
// <PageHeader
// title="Contacts"
// badge={16}
// infoText="People who reached out directly — phone, walk-in, referral."
// controls={<><Input .../> <Button .../></>}
// tabs={<Tabs ...><TabList ...>{...}</TabList></Tabs>}
// />
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { NotificationBell } from './notification-bell';
import { useAuth } from '@/providers/auth-provider';
interface PageHeaderProps {
title: string;
badge?: number | string;
/** Short inline text next to badge — use sparingly (e.g. "17 total") */
subtitle?: string;
/** Longer descriptive text shown on info icon hover/click */
infoText?: string;
controls?: ReactNode;
tabs?: ReactNode;
}
const InfoTooltip = ({ text }: { text: string }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
className="flex size-5 items-center justify-center rounded-full text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
title={text}
>
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-72 rounded-lg bg-primary px-3 py-2 text-xs text-tertiary shadow-lg ring-1 ring-secondary">
{text}
</div>
)}
</div>
);
};
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
const { isAdmin } = useAuth();
return (
<div className="shrink-0">
{/* Row 1: title + controls */}
<div className="flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">{title}</h1>
{badge != null && (
<span className="inline-flex items-center justify-center rounded-full bg-brand-secondary px-2 py-0.5 text-xs font-semibold text-white">
{badge}
</span>
)}
{subtitle && (
<span className="text-sm text-tertiary ml-1">{subtitle}</span>
)}
{infoText && <InfoTooltip text={infoText} />}
</div>
<div className="flex items-center gap-2">
{controls}
{isAdmin && <NotificationBell />}
</div>
</div>
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
{tabs && (
<div className="px-6">
{tabs}
</div>
)}
</div>
);
};

View File

@@ -12,13 +12,12 @@ import {
faHospitalUser, faHospitalUser,
faCalendarCheck, faCalendarCheck,
faPhone, faPhone,
faAddressBook,
faUsers, faUsers,
faArrowRightFromBracket, faArrowRightFromBracket,
faTowerBroadcast, faTowerBroadcast,
faChartLine,
faFileAudio, faFileAudio,
faPhoneMissed, faPhoneMissed,
faSlidersUp,
} from "@fortawesome/pro-duotone-svg-icons"; } from "@fortawesome/pro-duotone-svg-icons";
import { faIcon } from "@/lib/icon-wrapper"; import { faIcon } from "@/lib/icon-wrapper";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -31,6 +30,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
import type { NavItemType } from "@/components/application/app-navigation/config"; import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar"; import { Avatar } from "@/components/base/avatar/avatar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useUiFlags } from "@/hooks/use-ui-flags";
import { useAgentState } from "@/hooks/use-agent-state"; import { useAgentState } from "@/hooks/use-agent-state";
import { useThemeTokens } from "@/providers/theme-token-provider"; import { useThemeTokens } from "@/providers/theme-token-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state"; import { sidebarCollapsedAtom } from "@/state/sidebar-state";
@@ -45,15 +45,14 @@ const IconCommentDots = faIcon(faCommentDots);
const IconChartMixed = faIcon(faChartMixed); const IconChartMixed = faIcon(faChartMixed);
const IconGear = faIcon(faGear); const IconGear = faIcon(faGear);
const IconPhone = faIcon(faPhone); const IconPhone = faIcon(faPhone);
const IconAddressBook = faIcon(faAddressBook);
const IconClockRewind = faIcon(faClockRotateLeft); const IconClockRewind = faIcon(faClockRotateLeft);
const IconUsers = faIcon(faUsers); const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser); const IconHospitalUser = faIcon(faHospitalUser);
const IconCalendarCheck = faIcon(faCalendarCheck); const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast); const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio); const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed); const IconPhoneMissed = faIcon(faPhoneMissed);
const IconSlidersUp = faIcon(faSlidersUp);
type NavSection = { type NavSection = {
label: string; label: string;
@@ -64,12 +63,16 @@ const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') { if (role === 'admin') {
return [ return [
{ label: 'Supervisor', items: [ { label: 'Supervisor', items: [
// Team Performance retired as a nav entry — its surfaces
// (time breakdown, NPS/conversion, alerts, richer agent
// table) are now rolled into the Dashboard. The route is
// kept alive for reference but not linked in the sidebar.
{ label: 'Dashboard', href: '/', icon: IconGrid2 }, { label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast }, { label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
]}, ]},
{ label: 'Data & Reports', items: [ { label: 'Data & Reports', items: [
{ label: 'Leads', href: '/leads', icon: IconUsers }, { label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind }, { label: 'Call Log', href: '/call-history', icon: IconClockRewind },
@@ -79,10 +82,9 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Marketing', items: [ { label: 'Marketing', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
]}, ]},
{ label: 'Configuration', items: [ // Settings hub absorbs branding, rules, team, clinics, doctors,
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp }, // telephony, ai, widget — one entry, navigates to the hub which
{ label: 'Branding', href: '/branding', icon: IconGear }, // links to each section page.
]},
{ label: 'Admin', items: [ { label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear }, { label: 'Settings', href: '/settings', icon: IconGear },
]}, ]},
@@ -94,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Center', items: [ { label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind }, { label: 'Call History', href: '/call-history', icon: IconClockRewind },
{ label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
@@ -105,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Main', items: [ { label: 'Main', items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 }, { label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers }, { label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
@@ -135,7 +140,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom); const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null; const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const ozonetelState = useAgentState(agentId); const { state: ozonetelState } = useAgentState(agentId);
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline'; const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
@@ -152,7 +157,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
navigate('/login'); navigate('/login');
}; };
const navSections = getNavSections(user.role); const uiFlags = useUiFlags();
const navSections = getNavSections(user.role).map((section) => ({
...section,
items: uiFlags.setupManaged
// When setup is managed by the product team (per-tenant flag),
// hide the Settings entry from the nav. The route is also
// blocked in router-provider so a stray bookmark doesn't work.
? section.items.filter((item) => item.href !== '/settings')
: section.items,
})).filter((section) => section.items.length > 0);
const content = ( const content = (
<aside <aside
@@ -283,7 +297,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<div> <div>
<h3 className="text-lg font-semibold text-primary">Sign out?</h3> <h3 className="text-lg font-semibold text-primary">Sign out?</h3>
<p className="mt-1 text-sm text-tertiary"> <p className="mt-1 text-sm text-tertiary">
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected. You will be logged out of Helix Engage and your telephony account. Any active calls will be disconnected.
</p> </p>
</div> </div>
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">

View File

@@ -1,17 +1,12 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components'; import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components'; import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge'; import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag'; import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator'; import { AgeIndicator } from '@/components/shared/age-indicator';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatPhone, formatShortDate } from '@/lib/format'; import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities'; import type { Lead } from '@/types/entities';
@@ -25,6 +20,7 @@ type LeadTableProps = {
onSort: (field: string) => void; onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void; onViewActivity?: (lead: Lead) => void;
visibleColumns?: Set<string>; visibleColumns?: Set<string>;
selectionMode?: 'multiple' | 'none';
}; };
type TableRow = { type TableRow = {
@@ -55,6 +51,7 @@ export const LeadTable = ({
onSort, onSort,
onViewActivity, onViewActivity,
visibleColumns, visibleColumns,
selectionMode,
}: LeadTableProps) => { }: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null); const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
@@ -107,18 +104,17 @@ export const LeadTable = ({
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 }, { id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 }, { id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 }, { id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
]; ];
const columns = visibleColumns const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions') ? allColumns.filter(c => visibleColumns.has(c.id))
: allColumns; : allColumns;
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
<Table <Table
aria-label="Leads table" aria-label="Leads table"
selectionMode="multiple" selectionMode={selectionMode ?? 'multiple'}
selectionBehavior="toggle" selectionBehavior="toggle"
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
@@ -143,6 +139,7 @@ export const LeadTable = ({
const firstName = lead.contactName?.firstName ?? ''; const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? ''; const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || '\u2014'; const name = `${firstName} ${lastName}`.trim() || '\u2014';
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
const phone = lead.contactPhone?.[0] const phone = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0]) ? formatPhone(lead.contactPhone[0])
: '\u2014'; : '\u2014';
@@ -189,17 +186,6 @@ export const LeadTable = ({
<Table.Cell /> <Table.Cell />
<Table.Cell /> <Table.Cell />
<Table.Cell /> <Table.Cell />
<Table.Cell />
<Table.Cell>
<div className="flex gap-2">
<Button size="sm" color="primary">
Merge
</Button>
<Button size="sm" color="secondary">
Keep Separate
</Button>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
); );
} }
@@ -217,12 +203,18 @@ export const LeadTable = ({
key={row.id} key={row.id}
id={row.id} id={row.id}
className={cx( className={cx(
'group/row cursor-pointer',
isSpamRow && !isSelected && 'bg-warning-primary', isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
onAction={() => onViewActivity?.(lead)}
> >
{isCol('phone') && <Table.Cell> {isCol('phone') && <Table.Cell>
<span className="font-semibold text-primary">{phone}</span> {phoneRaw ? (
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>} </Table.Cell>}
{isCol('name') && <Table.Cell> {isCol('name') && <Table.Cell>
<span className="text-secondary">{name}</span> <span className="text-secondary">{name}</span>
@@ -306,15 +298,6 @@ export const LeadTable = ({
<span className="text-tertiary">0</span> <span className="text-tertiary">0</span>
)} )}
</Table.Cell>} </Table.Cell>}
<Table.Cell>
<Button
size="sm"
color="tertiary"
iconLeading={DotsVertical}
aria-label="Row actions"
onClick={() => onViewActivity?.(lead)}
/>
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}

View File

@@ -0,0 +1,84 @@
import type { ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button';
// Generic confirmation modal shown before any destructive edit to a
// patient's record. Used by the call-desk forms (appointment, enquiry)
// to gate the patient-name rename flow, but intentionally non-specific:
// any page that needs a "are you sure you want to change this patient
// field?" confirm should reuse this modal instead of building its own.
//
// The lock-by-default + explicit-confirm gate is deliberately heavy
// because patient edits cascade workspace-wide — they hit past
// appointments, lead history, AI summaries, and the Redis
// caller-resolution cache. The default path should always be "don't
// touch the record"; the only way to actually commit a change is
// clicking an Edit button, reading this prompt, and confirming.
//
// Styling matches the sign-out confirmation in sidebar.tsx — same
// warning circle, same button layout — so the weight of the action
// reads immediately.
type EditPatientConfirmModalProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
/** Modal heading. Defaults to "Edit patient details?". */
title?: string;
/** Body copy explaining the consequences of the edit. Accepts any
* ReactNode so callers can inline markup / inline the specific
* field being edited. A sensible generic default is provided. */
description?: ReactNode;
/** Confirm-button label. Defaults to "Yes, edit details". */
confirmLabel?: string;
};
const DEFAULT_TITLE = 'Edit patient details?';
const DEFAULT_DESCRIPTION = (
<>
You&apos;re about to change a detail on this patient&apos;s record. The update will cascade
across Helix Engage past appointments, lead history, and the AI summary all reflect
the new value. Only proceed if the current data is actually wrong; for all other
cases, cancel and continue with the current record.
</>
);
const DEFAULT_CONFIRM_LABEL = 'Yes, edit details';
export const EditPatientConfirmModal = ({
isOpen,
onOpenChange,
onConfirm,
title = DEFAULT_TITLE,
description = DEFAULT_DESCRIPTION,
confirmLabel = DEFAULT_CONFIRM_LABEL,
}: EditPatientConfirmModalProps) => (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="max-w-md">
<Dialog>
<div className="rounded-xl bg-primary p-6 shadow-xl">
<div className="flex flex-col items-center text-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
<FontAwesomeIcon icon={faUserPen} className="size-5 text-fg-warning-primary" />
</div>
<div>
<h3 className="text-lg font-semibold text-primary">{title}</h3>
<p className="mt-1 text-sm text-tertiary">{description}</p>
</div>
<div className="flex w-full gap-3">
<Button size="md" color="secondary" className="flex-1" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="md" color="primary-destructive" className="flex-1" onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</div>
</Dialog>
</Modal>
</ModalOverlay>
);

View File

@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
import { PinInput } from '@/components/base/pin-input/pin-input'; import { PinInput } from '@/components/base/pin-input/pin-input';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons'; import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from 'react'; import type { FC } from 'react';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const ShieldIcon: FC<{ className?: string }> = ({ className }) => ( const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faShieldKeyhole} className={className} /> <FontAwesomeIcon icon={faShieldKeyhole} className={className} />
@@ -20,9 +21,14 @@ type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string };
type FreeRow = { agentId: string; displayName: string };
type SessionStatus = { locked: LockedRow[]; free: FreeRow[] };
type MaintOtpModalProps = { type MaintOtpModalProps = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Phase-2 state: once the OTP passes and the action uses an agent
// picker, we swap the PIN input for a two-bucket list (Locked / Free)
// fetched from `agentPickerEndpoint`. The operator picks a locked
// agent, then Confirm posts to the real `endpoint`.
const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(null);
const [pickedAgentId, setPickedAgentId] = useState<string | null>(null);
// OTP is held across the two-phase flow so we don't force the user
// to re-enter it after the picker loads.
const [verifiedOtp, setVerifiedOtp] = useState<string | null>(null);
const reset = () => {
setOtp('');
setError(null);
setSessionStatus(null);
setPickedAgentId(null);
setVerifiedOtp(null);
setLoading(false);
};
const handleClose = () => {
onOpenChange(false);
reset();
};
const postMaint = async (endpoint: string, body: Record<string, any>, otpHeader: string) => {
const res = await fetch(`${API_URL}/api/maint/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, data };
};
const runPickerAction = async (pickedId: string, otpHeader: string) => {
if (!action) return;
setLoading(true);
setError(null);
const payload = { ...preStepPayload, agentId: pickedId };
const { ok, data } = await postMaint(action.endpoint, payload, otpHeader);
setLoading(false);
if (ok) {
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
reset();
} else {
setError(data.message ?? 'Failed');
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!action || otp.length < 6) return; if (!action || otp.length < 6) return;
@@ -43,40 +98,49 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
try { try {
// Two-phase agent-picker flow — OTP first, then fetch list,
// then the operator picks which agent to act on.
if (action.agentPickerEndpoint) {
const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp);
if (!ok) {
setError(data.message ?? 'Invalid maintenance code');
setLoading(false);
return;
}
setSessionStatus(data as SessionStatus);
setVerifiedOtp(otp);
setLoading(false);
return;
}
if (action.clientSideHandler) { if (action.clientSideHandler) {
// Client-side action — OTP verified by calling a dummy maint endpoint first const { ok, data } = await postMaint('force-ready', {}, otp);
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, { if (!ok) {
method: 'POST', setError(data.message ?? 'Invalid maintenance code');
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
});
if (!otpRes.ok) {
setError('Invalid maintenance code');
setLoading(false); setLoading(false);
return; return;
} }
const result = await action.clientSideHandler(preStepPayload); const result = await action.clientSideHandler(preStepPayload);
notify.success(action.label, result.message ?? 'Completed'); notify.success(action.label, result.message ?? 'Completed');
onOpenChange(false); onOpenChange(false);
setOtp(''); reset();
return;
}
// Default: single-shot endpoint with agentId from the CC agent's
// own local config (cc-agent context). Supervisors hitting this
// path without agent config used to get 400 — the agent-picker
// branch above is the fix.
const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
const { ok, data } = await postMaint(action.endpoint, payload, otp);
if (ok) {
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
reset();
} else { } else {
// Standard sidecar endpoint setError(data.message ?? 'Failed');
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-maint-otp': otp,
},
...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}),
});
const data = await res.json();
if (res.ok) {
console.log(`[MAINT] ${action.label}:`, data);
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
setOtp('');
} else {
setError(data.message ?? 'Failed');
}
} }
} catch { } catch {
setError('Request failed'); setError('Request failed');
@@ -90,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
}; };
const handleClose = () => {
onOpenChange(false);
setOtp('');
setError(null);
};
if (!action) return null; if (!action) return null;
const showOtp = !action.needsPreStep || preStepReady; const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp);
const showOtp = (!action.needsPreStep || preStepReady) && !showPicker;
const confirmDisabled = showPicker
? !pickedAgentId || loading
: otp.length < 6 || loading || (action.needsPreStep && !preStepReady);
const handleConfirm = async () => {
if (showPicker && pickedAgentId && verifiedOtp) {
await runPickerAction(pickedAgentId, verifiedOtp);
} else {
await handleSubmit();
}
};
return ( return (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable> <ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-[400px]"> <Modal className="sm:max-w-[440px]">
<Dialog> <Dialog>
{() => ( {() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden"> <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
@@ -116,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
{/* Pre-step content (e.g., campaign selection) */} {/* Pre-step content (e.g., campaign selection) */}
{action.needsPreStep && preStepContent && ( {action.needsPreStep && preStepContent && !showPicker && (
<div className="px-6 pb-4"> <div className="px-6 pb-4">
{preStepContent} {preStepContent}
</div> </div>
)} )}
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
{showOtp && ( {showOtp && (
<div className="flex flex-col items-center gap-2 px-6 pb-5"> <div className="flex flex-col items-center gap-2 px-6 pb-5">
<PinInput size="sm"> <PinInput size="sm">
@@ -150,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
)} )}
{showPicker && sessionStatus && (
<div className="px-6 pb-5 space-y-4">
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Locked ({sessionStatus.locked.length})
</p>
</div>
{sessionStatus.locked.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.locked.map((row) => {
const selected = pickedAgentId === row.agentId;
return (
<button
key={row.agentId}
type="button"
onClick={() => setPickedAgentId(row.agentId)}
className={cx(
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
selected
? 'border-brand bg-brand-primary_alt'
: 'border-secondary hover:border-brand hover:bg-secondary',
)}
>
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
<p className="text-xs text-tertiary truncate">
<code className="font-mono">{row.agentId}</code> held by {row.heldByIp}
</p>
<p className="text-xs text-quaternary">
since {new Date(row.lockedAt).toLocaleString()}
</p>
</div>
{selected && (
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
)}
</button>
);
})}
</div>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Free ({sessionStatus.free.length})
</p>
</div>
{sessionStatus.free.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No free agents.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.free.map((row) => (
<div
key={row.agentId}
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
>
<div className="min-w-0">
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
<p className="text-xs text-quaternary truncate">
<code className="font-mono">{row.agentId}</code>
</p>
</div>
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
</div>
))}
</div>
)}
</div>
{error && (
<p className="text-sm text-error-primary">{error}</p>
)}
</div>
)}
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4"> <div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={handleClose} className="flex-1"> <Button size="md" color="secondary" onClick={handleClose} className="flex-1">
@@ -158,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
<Button <Button
size="md" size="md"
color="primary" color="primary"
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)} isDisabled={confirmDisabled}
isLoading={loading} isLoading={loading}
onClick={handleSubmit} onClick={handleConfirm}
className="flex-1" className="flex-1"
> >
Confirm Confirm

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
import { useAuth } from '@/providers/auth-provider';
import { useUiFlags } from '@/hooks/use-ui-flags';
// Dismissible banner shown across the top of authenticated pages when
// the hospital workspace has incomplete setup steps AND the admin has
// already dismissed the auto-wizard. This is the "nudge" layer —
// a persistent reminder that setup is still outstanding, without the
// intrusion of the full-page wizard.
//
// Visibility rules:
// - Admin users only (other roles can't complete setup)
// - At least one setup step is still `completed: false`
// - `setup-state.wizardDismissed === true` (otherwise the wizard
// auto-shows on next login and this banner would be redundant)
// - Not dismissed in the current browser session (resets on reload)
export const ResumeSetupBanner = () => {
const { isAdmin } = useAuth();
const { setupManaged } = useUiFlags();
const [state, setState] = useState<SetupState | null>(null);
const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
);
useEffect(() => {
if (!isAdmin || dismissed || setupManaged) return;
getSetupState()
.then(setState)
.catch(() => {
// Non-fatal — if setup-state isn't reachable, just
// skip the banner. The wizard still works.
});
}, [isAdmin, dismissed, setupManaged]);
if (!isAdmin || !state || dismissed || setupManaged) return null;
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
if (incompleteCount === 0) return null;
// If the wizard hasn't been dismissed yet, the first-run redirect
// in login.tsx handles pushing the admin into /setup — no need
// for this nudge.
if (!state.wizardDismissed) return null;
const handleDismiss = () => {
sessionStorage.setItem('helix_resume_setup_dismissed', '1');
setDismissed(true);
};
return (
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-brand bg-brand-primary px-4 py-2">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faCircleInfo} className="size-4 text-brand-primary" />
<span className="text-sm text-primary">
<b>Finish setting up your hospital</b> {incompleteCount} step
{incompleteCount === 1 ? '' : 's'} still need your attention.
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
color="primary"
href="/setup"
iconTrailing={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)}
>
Resume setup
</Button>
<button
type="button"
onClick={handleDismiss}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary transition duration-100 ease-linear"
title="Dismiss for this session"
>
<FontAwesomeIcon icon={faXmark} className="size-3" />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,99 @@
import { Link } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowRight, faCircleCheck, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
type SectionStatus = 'complete' | 'incomplete' | 'unknown';
type SectionCardProps = {
title: string;
description: string;
icon: any;
iconColor?: string;
// Either navigate (href) OR intercept the click (onClick). When onClick
// is provided, href is ignored and the card renders as a button. Used
// while self-serve setup is disabled — all clicks go through a
// "contact product team" modal in settings.tsx.
href?: string;
onClick?: () => void;
status?: SectionStatus;
disabled?: boolean;
};
// Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
// its dedicated page or triggers a parent-owned callback.
export const SectionCard = ({
title,
description,
icon,
iconColor = 'text-brand-primary',
href,
onClick,
status = 'unknown',
disabled = false,
}: SectionCardProps) => {
const className = cx(
'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
disabled
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
: 'bg-primary hover:border-brand hover:shadow-md',
);
const body = (
<>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
</div>
<div className="min-w-0">
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
<p className="mt-1 text-xs text-tertiary">{description}</p>
</div>
</div>
{!disabled && (
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
)}
</div>
{status !== 'unknown' && (
<div className="mt-4 flex items-center gap-2 border-t border-secondary pt-3">
{status === 'complete' ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faCircleCheck} className="size-3.5" />
Configured
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-warning-primary">
<FontAwesomeIcon icon={faCircleExclamation} className="size-3.5" />
Setup needed
</span>
)}
</div>
)}
</>
);
if (disabled) {
return (
<div className={className}>
{body}
</div>
);
}
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
return (
<Link to={href ?? '#'} className={className}>
{body}
</Link>
);
};

View File

@@ -0,0 +1,27 @@
import { createContext } from 'react';
// Context that lets each WizardStep render content into the wizard
// shell's right pane via a portal — without lifting per-step data
// fetching up to the page. The shell sets `rightPaneEl` to the
// `<aside>` DOM node once it mounts; child WizardStep components read
// it and createPortal their `rightPane` prop into it.
//
// Why a portal and not a state-lifted prop on WizardShell:
// - The right pane is tightly coupled to the active step's data
// (e.g. "list of clinics created so far") which lives in the step
// component's state. Lifting that state to the page would mean
// duplicating the data-fetching layer, OR re-querying everything
// from the page.
// - Trying to pass `rightPane: ReactNode` upward via callbacks
// either causes a one-frame flash (useEffect) or violates the
// "no setState during render" rule.
// - Portals are React-native, no extra render cycles, and the
// DOM target is already part of the layout.
export type WizardLayoutContextValue = {
rightPaneEl: HTMLElement | null;
};
export const WizardLayoutContext = createContext<WizardLayoutContextValue>({
rightPaneEl: null,
});

View File

@@ -0,0 +1,426 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBuilding,
faCircle,
faCircleCheck,
faCopy,
faHeadset,
faPenToSquare,
faPhone,
faRobot,
faStethoscope,
faUser,
faUsers,
} from '@fortawesome/pro-duotone-svg-icons';
// Reusable right-pane preview components for the onboarding wizard.
// Each one is a pure presentation component that takes already-fetched
// data as props — the parent step component owns the state + fetches
// + refetches after a successful save. Keeping the panes data-only
// means the active step can pass the same source of truth to both
// the middle (form) pane and this preview without two GraphQL queries
// running side by side.
// Shared title/empty state primitives so every pane has the same
// visual rhythm.
const PaneCard = ({
title,
count,
children,
}: {
title: string;
count?: number;
children: React.ReactNode;
}) => (
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">{title}</p>
{typeof count === 'number' && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-tertiary">
{count}
</span>
)}
</div>
{children}
</div>
);
const EmptyState = ({ message }: { message: string }) => (
<div className="px-4 py-6 text-center text-xs text-tertiary">{message}</div>
);
// ---------------------------------------------------------------------------
// Identity step — short "about this step" card. Explains what the
// admin is configuring and where it shows up in the staff portal so
// the right pane stays useful even when there's nothing to list yet.
// ---------------------------------------------------------------------------
const IDENTITY_BULLETS: { title: string; body: string }[] = [
{
title: 'Hospital name',
body: 'Shown on the staff portal sidebar, the login screen, and every patient-facing widget greeting.',
},
{
title: 'Logo',
body: 'Used as the avatar at the top of the staff portal and on the website widget header. Square images work best.',
},
{
title: 'Brand identity',
body: 'Colors, fonts and login copy live on the full Branding page — open it from Settings any time after setup.',
},
];
export const IdentityRightPane = () => (
<PaneCard title="About this step">
<div className="px-4 py-4">
<p className="text-sm text-tertiary">
This is how patients and staff first see your hospital across Helix Engage.
Get the basics in now you can polish branding later.
</p>
<ul className="mt-4 flex flex-col gap-3">
{IDENTITY_BULLETS.map((b) => (
<li key={b.title} className="flex items-start gap-2.5">
<FontAwesomeIcon
icon={faCircleCheck}
className="mt-0.5 size-4 shrink-0 text-fg-brand-primary"
/>
<div>
<p className="text-sm font-semibold text-primary">{b.title}</p>
<p className="text-xs text-tertiary">{b.body}</p>
</div>
</li>
))}
</ul>
</div>
</PaneCard>
);
// ---------------------------------------------------------------------------
// Clinics step — list of clinics created so far.
// ---------------------------------------------------------------------------
export type ClinicSummary = {
id: string;
clinicName: string | null;
addressCity?: string | null;
clinicStatus?: string | null;
};
export const ClinicsRightPane = ({ clinics }: { clinics: ClinicSummary[] }) => (
<PaneCard title="Clinics added" count={clinics.length}>
{clinics.length === 0 ? (
<EmptyState message="No clinics yet — add your first one in the form on the left." />
) : (
<ul className="divide-y divide-secondary">
{clinics.map((c) => (
<li key={c.id} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faBuilding} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{c.clinicName ?? 'Unnamed clinic'}
</p>
<p className="truncate text-xs text-tertiary">
{c.addressCity ?? 'No city'}
{c.clinicStatus && ` · ${c.clinicStatus.toLowerCase()}`}
</p>
</div>
</li>
))}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// Doctors step — grouped by department, since the user explicitly asked
// for "doctors grouped by department" earlier in the design discussion.
// ---------------------------------------------------------------------------
export type DoctorSummary = {
id: string;
fullName: { firstName: string | null; lastName: string | null } | null;
department?: string | null;
specialty?: string | null;
};
const doctorDisplayName = (d: DoctorSummary): string => {
const first = d.fullName?.firstName?.trim() ?? '';
const last = d.fullName?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : 'Unnamed';
};
export const DoctorsRightPane = ({ doctors }: { doctors: DoctorSummary[] }) => {
// Group by department. Doctors with no department land in
// "Unassigned" so they're not silently dropped.
const grouped: Record<string, DoctorSummary[]> = {};
for (const d of doctors) {
const key = d.department?.trim() || 'Unassigned';
(grouped[key] ??= []).push(d);
}
const sortedKeys = Object.keys(grouped).sort();
return (
<PaneCard title="Doctors added" count={doctors.length}>
{doctors.length === 0 ? (
<EmptyState message="No doctors yet — add your first one in the form on the left." />
) : (
<div className="divide-y divide-secondary">
{sortedKeys.map((dept) => (
<div key={dept} className="px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
{dept}{' '}
<span className="text-tertiary">({grouped[dept].length})</span>
</p>
<ul className="mt-2 flex flex-col gap-2">
{grouped[dept].map((d) => (
<li
key={d.id}
className="flex items-start gap-2.5 text-sm text-primary"
>
<FontAwesomeIcon
icon={faStethoscope}
className="mt-0.5 size-3.5 text-fg-quaternary"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{doctorDisplayName(d)}
</p>
{d.specialty && (
<p className="truncate text-xs text-tertiary">
{d.specialty}
</p>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
)}
</PaneCard>
);
};
// ---------------------------------------------------------------------------
// Team step — list of employees with role + SIP badge.
// ---------------------------------------------------------------------------
export type TeamMemberSummary = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
roleLabel: string | null;
sipExtension: string | null;
// True if this row represents the currently logged-in admin —
// suppresses the edit/copy icons since admins shouldn't edit
// themselves from the wizard.
isCurrentUser: boolean;
// True if the parent has the plaintext temp password in memory
// (i.e. this employee was created in the current session).
// Drives whether the copy icon shows.
canCopyCredentials: boolean;
};
const memberDisplayName = (m: TeamMemberSummary): string => {
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
// Tiny icon button shared between the edit and copy actions on the
// employee row. Kept inline since it's only used here and the styling
// matches the existing right-pane density.
const RowIconButton = ({
icon,
title,
onClick,
}: {
icon: typeof faPenToSquare;
title: string;
onClick: () => void;
}) => (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
>
<FontAwesomeIcon icon={icon} className="size-3.5" />
</button>
);
export const TeamRightPane = ({
members,
onEdit,
onCopy,
}: {
members: TeamMemberSummary[];
onEdit?: (memberId: string) => void;
onCopy?: (memberId: string) => void;
}) => (
<PaneCard title="Employees" count={members.length}>
{members.length === 0 ? (
<EmptyState message="No employees yet — create your first one in the form on the left." />
) : (
<ul className="divide-y divide-secondary">
{members.map((m) => (
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faUser} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{memberDisplayName(m)}
</p>
<p className="truncate text-xs text-tertiary">
{m.userEmail}
{m.roleLabel && ` · ${m.roleLabel}`}
</p>
{m.sipExtension && (
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-success-secondary px-2 py-0.5 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faHeadset} className="size-3" />
{m.sipExtension}
</span>
)}
</div>
{/* Admin row gets neither button — admins
shouldn't edit themselves from here, and
their password isn't in our session
memory anyway. */}
{!m.isCurrentUser && (
<div className="flex shrink-0 items-center gap-1">
{m.canCopyCredentials && onCopy && (
<RowIconButton
icon={faCopy}
title="Copy login credentials"
onClick={() => onCopy(m.id)}
/>
)}
{onEdit && (
<RowIconButton
icon={faPenToSquare}
title="Edit employee"
onClick={() => onEdit(m.id)}
/>
)}
</div>
)}
</li>
))}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// Telephony step — live SIP → member mapping.
// ---------------------------------------------------------------------------
export type SipSeatSummary = {
id: string;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMember: {
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
const seatMemberLabel = (m: SipSeatSummary['workspaceMember']): string => {
if (!m) return 'Unassigned';
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
export const TelephonyRightPane = ({ seats }: { seats: SipSeatSummary[] }) => (
<PaneCard title="SIP seats" count={seats.length}>
{seats.length === 0 ? (
<EmptyState message="No SIP seats configured — contact support to provision seats." />
) : (
<ul className="divide-y divide-secondary">
{seats.map((seat) => {
const isAssigned = seat.workspaceMember !== null;
return (
<li key={seat.id} className="flex items-start gap-3 px-4 py-3">
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${
isAssigned
? 'bg-brand-secondary text-brand-secondary'
: 'bg-secondary text-quaternary'
}`}
>
<FontAwesomeIcon icon={faPhone} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
Ext {seat.sipExtension ?? '—'}
</p>
<p className="truncate text-xs text-tertiary">
{seatMemberLabel(seat.workspaceMember)}
</p>
</div>
{!isAssigned && (
<span className="inline-flex shrink-0 items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-tertiary">
Available
</span>
)}
</li>
);
})}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// AI step — static cards for each configured actor with last-edited info.
// Filled in once the backend prompt config refactor lands.
// ---------------------------------------------------------------------------
export type AiActorSummary = {
key: string;
label: string;
description: string;
lastEditedAt: string | null;
isCustom: boolean;
};
export const AiRightPane = ({ actors }: { actors: AiActorSummary[] }) => (
<PaneCard title="AI personas" count={actors.length}>
{actors.length === 0 ? (
<EmptyState message="Loading personas…" />
) : (
<ul className="divide-y divide-secondary">
{actors.map((a) => (
<li key={a.key} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{a.label}
</p>
<p className="truncate text-xs text-tertiary">
{a.isCustom
? `Edited ${a.lastEditedAt ? new Date(a.lastEditedAt).toLocaleDateString() : 'recently'}`
: 'Default'}
</p>
</div>
</li>
))}
</ul>
)}
</PaneCard>
);
// Suppress unused-import warnings for icons reserved for future use.
void faCircle;
void faUsers;

View File

@@ -0,0 +1,159 @@
import { useState, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faCircle } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { cx } from '@/utils/cx';
import { SETUP_STEP_NAMES, SETUP_STEP_LABELS, type SetupStepName, type SetupState } from '@/lib/setup-state';
import { WizardLayoutContext } from './wizard-layout-context';
type WizardShellProps = {
state: SetupState;
activeStep: SetupStepName;
onSelectStep: (step: SetupStepName) => void;
onDismiss: () => void;
// Form column (middle pane). The active step component renders
// its form into this slot. The right pane is filled via the
// WizardLayoutContext + a portal — see wizard-step.tsx.
children: ReactNode;
};
// Layout shell for the onboarding wizard. Three-pane layout:
// left — step navigator (fixed width)
// middle — form (flexible, the focus column)
// right — preview pane fed by the active step component (sticky,
// hides below xl breakpoint)
//
// The whole shell is `fixed inset-0` so the document body cannot
// scroll while the wizard is mounted — fixes the double-scrollbar
// bug where the body was rendered taller than the viewport and
// scrolled alongside the form column. The form and preview columns
// each scroll independently inside the shell.
//
// The header has a "Skip for now" affordance that dismisses the
// wizard for this workspace; once dismissed it never auto-shows
// again on login.
export const WizardShell = ({
state,
activeStep,
onSelectStep,
onDismiss,
children,
}: WizardShellProps) => {
const completedCount = SETUP_STEP_NAMES.filter((s) => state.steps[s].completed).length;
const totalSteps = SETUP_STEP_NAMES.length;
const progressPct = Math.round((completedCount / totalSteps) * 100);
// Callback ref → state — guarantees that consumers re-render once
// the aside is mounted (a plain useRef would not propagate the
// attached node back through the context). The element is also
// updated to null on unmount so the context is always honest about
// whether the slot is currently available for portals.
const [rightPaneEl, setRightPaneEl] = useState<HTMLElement | null>(null);
return (
<WizardLayoutContext.Provider value={{ rightPaneEl }}>
<div className="fixed inset-0 z-50 flex flex-col bg-primary">
{/* Header — pinned. Progress bar always visible (grey
track when 0%), sits flush under the title row. */}
<header className="shrink-0 border-b border-secondary bg-primary">
<div className="mx-auto flex w-full max-w-screen-2xl items-center justify-between gap-6 px-8 pt-4 pb-3">
<div className="flex items-center gap-4">
<div>
<h1 className="text-lg font-bold text-primary">Set up your hospital</h1>
<p className="text-xs text-tertiary">
{completedCount} of {totalSteps} steps complete · finish setup to start
using your workspace
</p>
</div>
</div>
<Button color="link-gray" size="sm" onClick={onDismiss}>
Skip for now
</Button>
</div>
<div className="mx-auto w-full max-w-screen-2xl px-8 pb-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-brand-solid transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</header>
{/* Body — three columns inside a fixed-height flex row.
min-h-0 on the row + each column lets the inner
overflow-y-auto actually take effect. */}
<div className="mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 gap-6 px-8 py-6">
{/* Left — step navigator. Scrolls if it overflows on
very short viewports, but in practice it fits. */}
<nav className="w-60 shrink-0 overflow-y-auto">
<ol className="flex flex-col gap-1">
{SETUP_STEP_NAMES.map((step, idx) => {
const meta = SETUP_STEP_LABELS[step];
const status = state.steps[step];
const isActive = step === activeStep;
const isComplete = status.completed;
return (
<li key={step}>
<button
type="button"
onClick={() => onSelectStep(step)}
className={cx(
'group flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition',
isActive
? 'border-brand bg-brand-primary'
: 'border-transparent hover:bg-secondary',
)}
>
<span className="mt-0.5 shrink-0">
<FontAwesomeIcon
icon={isComplete ? faCircleCheck : faCircle}
className={cx(
'size-5',
isComplete
? 'text-success-primary'
: 'text-quaternary',
)}
/>
</span>
<span className="flex-1">
<span className="block text-xs font-medium text-tertiary">
Step {idx + 1}
</span>
<span
className={cx(
'block text-sm font-semibold',
isActive ? 'text-brand-primary' : 'text-primary',
)}
>
{meta.title}
</span>
</span>
</button>
</li>
);
})}
</ol>
</nav>
{/* Middle — form column. min-w-0 prevents children from
forcing the column wider than its flex basis (long
inputs, etc.). overflow-y-auto so it scrolls
independently of the right pane. */}
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto">{children}</main>
{/* Right — preview pane. Always rendered as a stable
portal target (so the active step's WizardStep can
createPortal into it via WizardLayoutContext).
Hidden below xl breakpoint (1280px) so the wizard
collapses cleanly to two columns on smaller screens.
Independent scroll. */}
<aside
ref={setRightPaneEl}
className="hidden w-80 shrink-0 overflow-y-auto xl:block"
/>
</div>
</div>
</WizardLayoutContext.Provider>
);
};

View File

@@ -0,0 +1,434 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
import { WizardStep } from './wizard-step';
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
import { Button } from '@/components/base/buttons/button';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
import type { WizardStepComponentProps } from './wizard-step-types';
// AI step (post-prompt-config rework). The middle pane has two sections:
//
// 1. Provider / model / temperature picker — same as before, drives the
// provider that all actors use under the hood.
// 2. AI personas — list of 7 actor cards, each with name, description,
// truncated current template, and an Edit button. Edit triggers a
// confirmation modal warning about unintended consequences, then
// opens a slideout with the full template + a "variables you can
// use" reference + Save / Reset.
//
// The right pane shows the same 7 personas as compact "last edited" cards
// so the admin can scan recent activity at a glance.
//
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
// supervisor, lead enrichment, call insight, call assist, recording
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
// edit here lands instantly.
type ServerPromptConfig = {
label: string;
description: string;
variables: { key: string; description: string }[];
template: string;
defaultTemplate: string;
lastEditedAt: string | null;
lastEditedBy: string | null;
};
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
prompts?: Record<string, ServerPromptConfig>;
};
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
// ai.defaults.ts so the wizard renders personas in the same order
// admins see them documented elsewhere.
const ACTOR_ORDER = [
'widgetChat',
'ccAgentHelper',
'supervisorChat',
'leadEnrichment',
'callInsight',
'callAssist',
'recordingAnalysis',
] as const;
const truncate = (s: string, max: number): string =>
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
export const WizardStepAi = (props: WizardStepComponentProps) => {
const { user } = useAuth();
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Edit flow state — three phases:
// 1. confirmingActor: which actor's Edit button was just clicked
// (drives the confirmation modal)
// 2. editingActor: which actor's slideout is open (only set after
// the user confirms past the warning prompt)
// 3. draftTemplate: the current textarea contents in the slideout
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
const [editingActor, setEditingActor] = useState<string | null>(null);
const [draftTemplate, setDraftTemplate] = useState('');
const [savingPrompt, setSavingPrompt] = useState(false);
const fetchConfig = useCallback(async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: '',
});
setPrompts(data.prompts ?? {});
} catch (err) {
console.error('[wizard/ai] fetch failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const handleSaveProviderConfig = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
const temperature = Number(values.temperature);
setSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
});
notify.success('AI settings saved', 'Provider and model updated.');
await fetchConfig();
if (!props.isCompleted) {
await props.onComplete('ai');
}
} catch (err) {
console.error('[wizard/ai] save provider failed', err);
} finally {
setSaving(false);
}
};
// Confirmation modal → slideout flow.
const handleEditClick = (actor: string) => {
setConfirmingActor(actor);
};
const handleConfirmEdit = () => {
if (!confirmingActor) return;
const prompt = prompts[confirmingActor];
if (!prompt) return;
setEditingActor(confirmingActor);
setDraftTemplate(prompt.template);
setConfirmingActor(null);
};
const handleSavePrompt = async (close: () => void) => {
if (!editingActor) return;
if (!draftTemplate.trim()) {
notify.error('Prompt cannot be empty');
return;
}
setSavingPrompt(true);
try {
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
template: draftTemplate,
editedBy: user?.email ?? null,
});
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] save prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
const handleResetPrompt = async (close: () => void) => {
if (!editingActor) return;
setSavingPrompt(true);
try {
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] reset prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
// Build the right-pane summary entries from the loaded prompts.
// `isCustom` is true when the template differs from the shipped
// default OR when the audit fields are populated — either way the
// admin has touched it.
const actorSummaries = useMemo<AiActorSummary[]>(() => {
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
const p = prompts[key];
return {
key,
label: p.label,
description: p.description,
lastEditedAt: p.lastEditedAt,
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
};
});
}, [prompts]);
const editingPrompt = editingActor ? prompts[editingActor] : null;
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
return (
<WizardStep
step="ai"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSaveProviderConfig}
onFinish={props.onFinish}
saving={saving}
rightPane={<AiRightPane actors={actorSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading AI settings</p>
) : (
<div className="flex flex-col gap-8">
<section>
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
<AiForm value={values} onChange={setValues} />
</section>
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
<span className="text-xs text-tertiary">
{actorSummaries.length} configurable prompts
</span>
</div>
<p className="mb-4 text-xs text-tertiary">
Each persona below is a different AI surface in Helix Engage. Editing a
prompt changes how that persona sounds and what rules it follows. Defaults
are tuned for hospital call centers only edit if you have a specific
reason and can test the result.
</p>
<ul className="flex flex-col gap-3">
{ACTOR_ORDER.map((key) => {
const prompt = prompts[key];
if (!prompt) return null;
const isCustom =
prompt.template !== prompt.defaultTemplate ||
prompt.lastEditedAt !== null;
return (
<li
key={key}
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
>
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-primary">
{prompt.label}
</h4>
<p className="mt-0.5 text-xs text-tertiary">
{prompt.description}
</p>
</div>
{isCustom && (
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
Edited
</span>
)}
</div>
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
{truncate(prompt.template, 220)}
</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
color="secondary"
onClick={() => handleEditClick(key)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon
icon={faPenToSquare}
className={className}
/>
)}
>
Edit
</Button>
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
)}
{/* Confirmation modal — reused from the patient-edit gate. */}
<EditPatientConfirmModal
isOpen={confirmingActor !== null}
onOpenChange={(open) => {
if (!open) setConfirmingActor(null);
}}
onConfirm={handleConfirmEdit}
title={`Edit ${confirmingLabel} prompt?`}
description={
<>
Modifying this prompt can affect call quality, lead summaries, and supervisor
insights in ways that are hard to predict. The defaults are tuned for hospital
call centers only edit if you have a specific reason and can test the
result. You can always reset back to default from the editor.
</>
}
confirmLabel="Yes, edit prompt"
/>
{/* Slideout editor — only opens after the warning is confirmed. */}
<SlideoutMenu
isOpen={editingActor !== null}
onOpenChange={(open) => {
if (!open) setEditingActor(null);
}}
isDismissable
>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon
icon={faRobot}
className="size-5 text-fg-brand-primary"
/>
</div>
<div>
<h2 className="text-lg font-semibold text-primary">
Edit {editingPrompt?.label}
</h2>
<p className="text-sm text-tertiary">
{editingPrompt?.description}
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-secondary">
Prompt template
</label>
<textarea
value={draftTemplate}
onChange={(e) => setDraftTemplate(e.target.value)}
rows={18}
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
/>
<p className="mt-1 text-xs text-tertiary">
Variables wrapped in <code>{'{{double-braces}}'}</code> get
substituted at runtime with live data.
</p>
</div>
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
<div className="rounded-lg border border-secondary bg-secondary p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Variables you can use
</p>
<ul className="mt-2 flex flex-col gap-1.5">
{editingPrompt.variables.map((v) => (
<li key={v.key} className="flex items-start gap-2 text-xs">
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
{`{{${v.key}}}`}
</code>
<span className="text-tertiary">
{v.description}
</span>
</li>
))}
</ul>
</div>
)}
{editingPrompt?.lastEditedAt && (
<p className="text-xs text-tertiary">
Last edited{' '}
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
</p>
)}
</div>
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-between gap-3">
<Button
size="md"
color="link-gray"
isDisabled={savingPrompt}
onClick={() => handleResetPrompt(close)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
>
Reset to default
</Button>
<div className="flex items-center gap-3">
<Button
size="md"
color="secondary"
isDisabled={savingPrompt}
onClick={close}
>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={savingPrompt}
showTextWhileLoading
onClick={() => handleSavePrompt(close)}
>
{savingPrompt ? 'Saving…' : 'Save prompt'}
</Button>
</div>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</WizardStep>
);
};

View File

@@ -0,0 +1,171 @@
import { useCallback, useEffect, useState } from 'react';
import { WizardStep } from './wizard-step';
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
import {
ClinicForm,
clinicCoreToGraphQLInput,
holidayInputsFromForm,
requiredDocInputsFromForm,
emptyClinicFormValues,
type ClinicFormValues,
} from '@/components/forms/clinic-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Clinic step — presents a single-clinic form. On save the wizard runs
// a three-stage create chain:
// 1. createClinic (main record → get id)
// 2. createHoliday × N (one per holiday entry)
// 3. createClinicRequiredDocument × N (one per required doc type)
//
// This mirrors what the /settings/clinics list page does, minus the
// delete-old-first step (wizard is always creating, never updating).
// Failures inside the chain throw up through onComplete so the user
// sees the error loud, and the wizard stays on the current step.
export const WizardStepClinics = (props: WizardStepComponentProps) => {
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
const [saving, setSaving] = useState(false);
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
const fetchClinics = useCallback(async () => {
try {
// Field names match what the platform actually exposes:
// - the SDK ADDRESS field is named "address" but the
// platform mounts it as `addressCustom` (composite type
// with addressCity / addressStreet / etc.)
// - the SDK SELECT field labelled "Status" lands as plain
// `status: ClinicStatusEnum`, NOT `clinicStatus`
// Verified via __type introspection — keep this query
// pinned to the actual schema to avoid silent empty fetches.
type ClinicNode = {
id: string;
clinicName: string | null;
addressCustom: { addressCity: string | null } | null;
status: string | null;
};
const data = await apiClient.graphql<{
clinics: { edges: { node: ClinicNode }[] };
}>(
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
edges { node {
id clinicName
addressCustom { addressCity }
status
} }
} }`,
undefined,
{ silent: true },
);
// Flatten into the shape ClinicsRightPane expects.
setClinics(
data.clinics.edges.map((e) => ({
id: e.node.id,
clinicName: e.node.clinicName,
addressCity: e.node.addressCustom?.addressCity ?? null,
clinicStatus: e.node.status,
})),
);
} catch (err) {
console.error('[wizard/clinics] fetch failed', err);
}
}, []);
useEffect(() => {
fetchClinics();
}, [fetchClinics]);
const handleSave = async () => {
if (!values.clinicName.trim()) {
notify.error('Clinic name is required');
return;
}
setSaving(true);
try {
// 1. Core clinic record
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
`mutation CreateClinic($data: ClinicCreateInput!) {
createClinic(data: $data) { id }
}`,
{ data: clinicCoreToGraphQLInput(values) },
);
const clinicId = res.createClinic.id;
// 2. Holidays
if (values.holidays.length > 0) {
const holidayInputs = holidayInputsFromForm(values, clinicId);
await Promise.all(
holidayInputs.map((data) =>
apiClient.graphql(
`mutation CreateHoliday($data: HolidayCreateInput!) {
createHoliday(data: $data) { id }
}`,
{ data },
),
),
);
}
// 3. Required documents
if (values.requiredDocumentTypes.length > 0) {
const docInputs = requiredDocInputsFromForm(values, clinicId);
await Promise.all(
docInputs.map((data) =>
apiClient.graphql(
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
createClinicRequiredDocument(data: $data) { id }
}`,
{ data },
),
),
);
}
notify.success('Clinic added', values.clinicName);
await fetchClinics();
// Mark complete on first successful create. Don't auto-advance —
// admins typically add multiple clinics in one sitting; the
// Continue button on the wizard nav handles forward motion.
if (!props.isCompleted) {
await props.onComplete('clinics');
}
setValues(emptyClinicFormValues());
} catch (err) {
console.error('[wizard/clinics] save failed', err);
} finally {
setSaving(false);
}
};
// Same trick as the Team step: once at least one clinic exists,
// flip isCompleted=true so the WizardStep renders the "Continue"
// button as the primary action — the form stays open below for
// adding more clinics.
const pretendCompleted = props.isCompleted || clinics.length > 0;
return (
<WizardStep
step="clinics"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<ClinicsRightPane clinics={clinics} />}
>
<ClinicForm value={values} onChange={setValues} />
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving ? 'Adding…' : 'Add clinic'}
</button>
</div>
</WizardStep>
);
};

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { WizardStep } from './wizard-step';
import { DoctorsRightPane, type DoctorSummary } from './wizard-right-panes';
import {
DoctorForm,
doctorCoreToGraphQLInput,
visitSlotInputsFromForm,
emptyDoctorFormValues,
type DoctorFormValues,
} from '@/components/forms/doctor-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Doctor step — mirrors the clinics step but also fetches the clinic list
// for the DoctorForm's clinic dropdown. If there are no clinics yet we let
// the admin know they need to complete step 2 first (the wizard doesn't
// force ordering, but a doctor without a clinic is useless).
type ClinicLite = { id: string; clinicName: string | null };
export const WizardStepDoctors = (props: WizardStepComponentProps) => {
const [values, setValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
const [clinics, setClinics] = useState<ClinicLite[]>([]);
const [doctors, setDoctors] = useState<DoctorSummary[]>([]);
const [loadingClinics, setLoadingClinics] = useState(true);
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
try {
const data = await apiClient.graphql<{
clinics: { edges: { node: ClinicLite }[] };
doctors: { edges: { node: DoctorSummary }[] };
}>(
`{
clinics(first: 100) { edges { node { id clinicName } } }
doctors(first: 100, orderBy: { createdAt: DescNullsLast }) {
edges { node { id fullName { firstName lastName } department specialty } }
}
}`,
undefined,
{ silent: true },
);
setClinics(data.clinics.edges.map((e) => e.node));
setDoctors(data.doctors.edges.map((e) => e.node));
} catch (err) {
console.error('[wizard/doctors] fetch failed', err);
setClinics([]);
setDoctors([]);
} finally {
setLoadingClinics(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const clinicOptions = useMemo(
() => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })),
[clinics],
);
const handleSave = async () => {
if (!values.firstName.trim() || !values.lastName.trim()) {
notify.error('First and last name are required');
return;
}
setSaving(true);
try {
// 1. Core doctor record
const res = await apiClient.graphql<{ createDoctor: { id: string } }>(
`mutation CreateDoctor($data: DoctorCreateInput!) {
createDoctor(data: $data) { id }
}`,
{ data: doctorCoreToGraphQLInput(values) },
);
const doctorId = res.createDoctor.id;
// 2. Visit slots (doctor can be at multiple clinics on
// multiple days with different times each).
const slotInputs = visitSlotInputsFromForm(values, doctorId);
if (slotInputs.length > 0) {
await Promise.all(
slotInputs.map((data) =>
apiClient.graphql(
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
createDoctorVisitSlot(data: $data) { id }
}`,
{ data },
),
),
);
}
notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`);
await fetchData();
if (!props.isCompleted) {
await props.onComplete('doctors');
}
setValues(emptyDoctorFormValues());
} catch (err) {
console.error('[wizard/doctors] save failed', err);
} finally {
setSaving(false);
}
};
const pretendCompleted = props.isCompleted || doctors.length > 0;
return (
<WizardStep
step="doctors"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<DoctorsRightPane doctors={doctors} />}
>
{loadingClinics ? (
<p className="text-sm text-tertiary">Loading clinics</p>
) : clinics.length === 0 ? (
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
<p className="mt-1 text-xs text-tertiary">
You need at least one clinic before you can assign doctors. Go back to the{' '}
<b>Clinics</b> step and add a branch first.
</p>
</div>
) : (
<>
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving ? 'Adding…' : 'Add doctor'}
</button>
</div>
</>
)}
</WizardStep>
);
};

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Input } from '@/components/base/input/input';
import { WizardStep } from './wizard-step';
import { IdentityRightPane } from './wizard-right-panes';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Minimal identity step — just the two most important fields (hospital name
// and logo URL). Full branding (colors, fonts, login copy) is handled on the
// /branding page and linked from here. Keeping the wizard lean means admins
// can clear setup in under ten minutes; the branding page is there whenever
// they want to polish further.
const THEME_API_URL =
import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const WizardStepIdentity = (props: WizardStepComponentProps) => {
const [hospitalName, setHospitalName] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch(`${THEME_API_URL}/api/config/theme`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.brand) {
setHospitalName(data.brand.hospitalName ?? '');
setLogoUrl(data.brand.logo ?? '');
}
})
.catch(() => {
// non-fatal — admin can fill in fresh values
})
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
if (!hospitalName.trim()) {
notify.error('Hospital name is required');
return;
}
setSaving(true);
try {
const response = await fetch(`${THEME_API_URL}/api/config/theme`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brand: {
hospitalName: hospitalName.trim(),
logo: logoUrl.trim() || undefined,
},
}),
});
if (!response.ok) throw new Error(`PUT /api/config/theme failed: ${response.status}`);
notify.success('Identity saved', 'Hospital name and logo updated.');
await props.onComplete('identity');
props.onAdvance();
} catch (err) {
notify.error('Save failed', 'Could not update hospital identity. Please try again.');
console.error('[wizard/identity] save failed', err);
} finally {
setSaving(false);
}
};
return (
<WizardStep
step="identity"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<IdentityRightPane />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading current branding</p>
) : (
<div className="flex flex-col gap-5">
<Input
label="Hospital name"
isRequired
placeholder="e.g. Ramaiah Memorial Hospital"
value={hospitalName}
onChange={setHospitalName}
/>
<Input
label="Logo URL"
placeholder="https://yourhospital.com/logo.png"
hint="Paste a URL to your hospital logo. Square images work best."
value={logoUrl}
onChange={setLogoUrl}
/>
{logoUrl && (
<div className="flex items-center gap-3 rounded-lg border border-secondary bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary">Preview:</span>
<img
src={logoUrl}
alt="Logo preview"
className="size-10 rounded-lg border border-secondary bg-primary object-contain p-1"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
</div>
)}
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-4">
<p className="text-xs text-tertiary">
Need to pick brand colors, fonts, or customise the login page copy? Open the full{' '}
<Link to="/branding" className="font-semibold text-brand-primary underline">
branding settings
</Link>{' '}
page after completing setup.
</p>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -0,0 +1,441 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { WizardStep } from './wizard-step';
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
import {
EmployeeCreateForm,
emptyEmployeeCreateFormValues,
generateTempPassword,
type EmployeeCreateFormValues,
type RoleOption,
} from '@/components/forms/employee-create-form';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
import type { WizardStepComponentProps } from './wizard-step-types';
// Team step (post-rework) — creates workspace members directly from
// the portal via the sidecar's /api/team/members endpoint. The admin
// enters name + email + temp password + role. SIP seat assignment is
// NOT done here — it lives exclusively in the Telephony wizard step
// so admins manage one thing in one place.
//
// Edit mode: clicking the pencil icon on an employee row in the right
// pane loads that member back into the form (name + role only — email,
// password and SIP seat are not editable here). Save in edit mode
// fires PUT /api/team/members/:id instead of POST.
//
// Email invitations are NOT used anywhere in this flow. The admin is
// expected to share the temp password with the employee directly.
// Recently-created employees keep their plaintext password in
// component state so the right pane's copy icon can paste a
// shareable credentials block to the clipboard. Page reload clears
// that state — only employees created in the current session show
// the copy icon. Older members get only the edit icon.
// In-memory record of an employee the admin just created in this
// session. Holds the plaintext temp password so the copy-icon flow
// works without ever sending the password back from the server.
type CreatedMemberRecord = {
id: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
tempPassword: string;
};
type RoleRow = {
id: string;
label: string;
description: string | null;
canBeAssignedToUsers: boolean;
};
type AgentRow = {
id: string;
name: string | null;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMemberId: string | null;
workspaceMember: {
id: string;
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
type WorkspaceMemberRow = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
// Platform returns `null` (not an empty array) for members with no
// role assigned — touching `.roles[0]` directly throws. Always
// optional-chain reads.
roles: { id: string; label: string }[] | null;
};
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
// Build the credentials block that gets copied to the clipboard. Two
// lines (login url + email) plus the temp password — formatted so
// the admin can paste it straight into WhatsApp / SMS. Login URL is
// derived from the current browser origin since the wizard is always
// loaded from the workspace's own URL (or Vite dev), so this matches
// what the employee will use.
const buildCredentialsBlock = (email: string, tempPassword: string): string => {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`;
};
export const WizardStepTeam = (props: WizardStepComponentProps) => {
const { user } = useAuth();
const currentUserEmail = user?.email ?? null;
// Initialise the form with a fresh temp password so the admin
// doesn't have to click "regenerate" before saving the very first
// employee.
const [values, setValues] = useState<EmployeeCreateFormValues>(() => ({
...emptyEmployeeCreateFormValues,
password: generateTempPassword(),
}));
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [roles, setRoles] = useState<RoleOption[]>([]);
// Agents are still fetched (even though we don't show a SIP seat
// picker here) because the right-pane summary needs each member's
// current SIP extension to show the green badge.
const [agents, setAgents] = useState<AgentRow[]>([]);
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
const [createdMembers, setCreatedMembers] = useState<CreatedMemberRecord[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const isEditing = editingMemberId !== null;
const fetchRolesAndAgents = useCallback(async () => {
try {
const data = await apiClient.graphql<{
getRoles: RoleRow[];
agents: { edges: { node: AgentRow }[] };
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
}>(
`{
getRoles { id label description canBeAssignedToUsers }
agents(first: 100) {
edges { node {
id name sipExtension ozonetelAgentId workspaceMemberId
workspaceMember { id name { firstName lastName } userEmail }
} }
}
workspaceMembers(first: 200) {
edges { node {
id userEmail name { firstName lastName }
roles { id label }
} }
}
}`,
undefined,
{ silent: true },
);
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
setRoles(
assignable.map((r) => ({
id: r.id,
label: r.label,
supportingText: r.description ?? undefined,
})),
);
setAgents(data.agents.edges.map((e) => e.node));
setMembers(
data.workspaceMembers.edges
.map((e) => e.node)
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
);
} catch (err) {
console.error('[wizard/team] fetch roles/agents failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRolesAndAgents();
}, [fetchRolesAndAgents]);
// Reset form back to a fresh "create" state with a new auto-gen
// password. Used after both create-success and edit-cancel.
const resetForm = () => {
setEditingMemberId(null);
setValues({
...emptyEmployeeCreateFormValues,
password: generateTempPassword(),
});
};
const handleSaveCreate = async () => {
const firstName = values.firstName.trim();
const email = values.email.trim();
if (!firstName) {
notify.error('First name is required');
return;
}
if (!email) {
notify.error('Email is required');
return;
}
if (!values.password) {
notify.error('Temporary password is required');
return;
}
if (!values.roleId) {
notify.error('Pick a role');
return;
}
setSaving(true);
try {
const created = await apiClient.post<{
id: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
}>('/api/team/members', {
firstName,
lastName: values.lastName.trim(),
email,
password: values.password,
roleId: values.roleId,
});
// Stash the plaintext temp password alongside the created
// member so the copy-icon can build a credentials block
// later. The password is NOT sent back from the server —
// we hold the only copy in this component's memory.
setCreatedMembers((prev) => [
...prev,
{ ...created, tempPassword: values.password },
]);
notify.success(
'Employee created',
`${firstName} ${values.lastName.trim()}`.trim() || email,
);
await fetchRolesAndAgents();
resetForm();
if (!props.isCompleted) {
await props.onComplete('team');
}
} catch (err) {
console.error('[wizard/team] create failed', err);
} finally {
setSaving(false);
}
};
const handleSaveUpdate = async () => {
if (!editingMemberId) return;
const firstName = values.firstName.trim();
if (!firstName) {
notify.error('First name is required');
return;
}
if (!values.roleId) {
notify.error('Pick a role');
return;
}
setSaving(true);
try {
await apiClient.put(`/api/team/members/${editingMemberId}`, {
firstName,
lastName: values.lastName.trim(),
roleId: values.roleId,
});
notify.success(
'Employee updated',
`${firstName} ${values.lastName.trim()}`.trim() || values.email,
);
await fetchRolesAndAgents();
resetForm();
} catch (err) {
console.error('[wizard/team] update failed', err);
} finally {
setSaving(false);
}
};
const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate;
// Right-pane edit handler — populate the form with the picked
// member's data and switch into edit mode. Email is preserved as
// the row's email (read-only in edit mode); password is cleared
// since the form hides the field anyway.
const handleEditMember = (memberId: string) => {
const member = members.find((m) => m.id === memberId);
if (!member) return;
const firstRole = member.roles?.[0] ?? null;
setEditingMemberId(memberId);
setValues({
firstName: member.name?.firstName ?? '',
lastName: member.name?.lastName ?? '',
email: member.userEmail,
password: '',
roleId: firstRole?.id ?? '',
});
};
// Right-pane copy handler — build the shareable credentials block
// and put it on the clipboard. Only fires for members in the
// createdMembers in-memory map; rows without a known temp password
// don't show the icon at all.
const handleCopyCredentials = async (memberId: string) => {
const member = members.find((m) => m.id === memberId);
if (!member) return;
// Three-tier fallback:
// 1. In-browser memory (createdMembers state) — populated when
// the admin created this employee in the current session,
// survives until refresh. Fastest path, no network call.
// 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password
// — populated for any member created via this endpoint
// within the last 24h, survives reloads.
// 3. Cache miss → tell the admin the password is no longer
// recoverable and direct them to the platform reset flow.
const fromMemory =
createdMembers.find(
(c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(),
) ?? createdMembers.find((c) => c.id === memberId);
let tempPassword = fromMemory?.tempPassword ?? null;
if (!tempPassword) {
try {
const res = await apiClient.get<{ password: string | null }>(
`/api/team/members/${memberId}/temp-password`,
{ silent: true },
);
tempPassword = res.password;
} catch (err) {
console.error('[wizard/team] temp-password fetch failed', err);
}
}
if (!tempPassword) {
notify.error(
'Password unavailable',
'The temp password expired (>24h). Reset the password from settings to mint a new one.',
);
return;
}
const block = buildCredentialsBlock(member.userEmail, tempPassword);
try {
await navigator.clipboard.writeText(block);
notify.success('Copied', 'Credentials copied to clipboard');
} catch (err) {
console.error('[wizard/team] clipboard write failed', err);
notify.error('Copy failed', 'Could not write to clipboard');
}
};
// Trick: we lie to WizardStep about isCompleted so that once at
// least one employee exists, the primary wizard button flips to
// "Continue" and the create form stays available below for more
// adds.
const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0;
// Build the right pane summary. Every non-admin row gets the
// copy icon — `canCopyCredentials: true` unconditionally — and
// the click handler figures out at action time whether to read
// from in-browser memory or the sidecar's Redis cache. If both
// are empty (>24h old), the click toasts a "password expired"
// message instead of silently failing.
const teamSummaries = useMemo<TeamMemberSummary[]>(
() =>
members.map((m) => {
const seat = agents.find((a) => a.workspaceMemberId === m.id);
const firstRole = m.roles?.[0] ?? null;
return {
id: m.id,
userEmail: m.userEmail,
name: m.name,
roleLabel: firstRole?.label ?? null,
sipExtension: seat?.sipExtension ?? null,
isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail,
canCopyCredentials: true,
};
}),
[members, agents, currentUserEmail],
);
return (
<WizardStep
step="team"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={
<TeamRightPane
members={teamSummaries}
onEdit={handleEditMember}
onCopy={handleCopyCredentials}
/>
}
>
{loading ? (
<p className="text-sm text-tertiary">Loading team settings</p>
) : (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
{isEditing ? (
<p>
Editing an existing employee. You can change their name and role.
To change their SIP seat, go to the <b>Telephony</b> step.
</p>
) : (
<p>
Create employees in-place. Each person gets an auto-generated
temporary password that you share directly no email
invitations are sent. Click the eye icon to reveal it before
you save. After creating CC agents, head to the <b>Telephony</b>{' '}
step to assign them SIP seats.
</p>
)}
</div>
<EmployeeCreateForm
value={values}
onChange={setValues}
roles={roles}
mode={isEditing ? 'edit' : 'create'}
/>
<div className="flex items-center justify-end gap-3">
{isEditing && (
<Button size="md" color="secondary" isDisabled={saving} onClick={resetForm}>
Cancel
</Button>
)}
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving
? isEditing
? 'Updating…'
: 'Creating…'
: isEditing
? 'Update employee'
: 'Create employee'}
</button>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { WizardStep } from './wizard-step';
import { TelephonyRightPane, type SipSeatSummary } from './wizard-right-panes';
import { Select } from '@/components/base/select/select';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Telephony step (post-3-pane rework). The middle pane is now an
// assign/unassign editor: pick a SIP seat, pick a workspace member,
// click Assign — or pick an already-mapped seat and click Unassign.
// The right pane shows the live current state (read-only mapping
// summary). Editing here calls updateAgent to set/clear
// workspaceMemberId, then refetches.
//
// SIP seats themselves are pre-provisioned by onboard-hospital.sh
// (see step 5b) — admins can't add or delete seats from this UI,
// only link them to people. To add a new seat, contact support.
type AgentRow = {
id: string;
name: string | null;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMemberId: string | null;
workspaceMember: {
id: string;
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
type WorkspaceMemberRow = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
};
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
const memberDisplayName = (m: {
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
}): string => {
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
export const WizardStepTelephony = (props: WizardStepComponentProps) => {
const [agents, setAgents] = useState<AgentRow[]>([]);
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Editor state — which seat is selected, which member to assign.
const [selectedSeatId, setSelectedSeatId] = useState<string>('');
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
const fetchData = useCallback(async () => {
try {
const data = await apiClient.graphql<{
agents: { edges: { node: AgentRow }[] };
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
}>(
`{
agents(first: 100) {
edges { node {
id name sipExtension ozonetelAgentId workspaceMemberId
workspaceMember { id name { firstName lastName } userEmail }
} }
}
workspaceMembers(first: 200) {
edges { node {
id userEmail name { firstName lastName }
} }
}
}`,
undefined,
{ silent: true },
);
setAgents(data.agents.edges.map((e) => e.node));
setMembers(
data.workspaceMembers.edges
.map((e) => e.node)
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
);
} catch (err) {
console.error('[wizard/telephony] fetch failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// Map every agent to a SipSeatSummary for the right pane. Single
// source of truth — both panes read from `agents`.
const seatSummaries = useMemo<SipSeatSummary[]>(
() =>
agents.map((a) => ({
id: a.id,
sipExtension: a.sipExtension,
ozonetelAgentId: a.ozonetelAgentId,
workspaceMember: a.workspaceMember,
})),
[agents],
);
// Pre-compute lookups for the editor — which member already owns
// each seat, and which members are already taken (so the dropdown
// can hide them).
const takenMemberIds = useMemo(
() =>
new Set(
agents
.filter((a) => a.workspaceMemberId !== null)
.map((a) => a.workspaceMemberId!),
),
[agents],
);
const seatItems = useMemo(
() =>
agents.map((a) => ({
id: a.id,
label: `Ext ${a.sipExtension ?? '—'}`,
supportingText: a.workspaceMember
? `Currently: ${memberDisplayName(a.workspaceMember)}`
: 'Unassigned',
})),
[agents],
);
// Members dropdown — when a seat is selected and the seat is
// currently mapped, force the member field to show the current
// owner so the admin can see who they're displacing. When seat
// is unassigned, only show free members (the takenMemberIds
// filter).
const memberItems = useMemo(() => {
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
const currentOwnerId = selectedSeat?.workspaceMemberId ?? null;
return members
.filter((m) => m.id === currentOwnerId || !takenMemberIds.has(m.id))
.map((m) => ({
id: m.id,
label: memberDisplayName(m),
supportingText: m.userEmail,
}));
}, [members, agents, selectedSeatId, takenMemberIds]);
// When the admin picks a seat, default the member dropdown to
// whoever currently owns it (if anyone) so Unassign just works.
useEffect(() => {
if (!selectedSeatId) {
setSelectedMemberId('');
return;
}
const seat = agents.find((a) => a.id === selectedSeatId);
setSelectedMemberId(seat?.workspaceMemberId ?? '');
}, [selectedSeatId, agents]);
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
const isCurrentlyMapped = selectedSeat?.workspaceMemberId !== null && selectedSeat?.workspaceMemberId !== undefined;
const updateSeat = async (seatId: string, workspaceMemberId: string | null) => {
setSaving(true);
try {
await apiClient.graphql(
`mutation UpdateAgent($id: UUID!, $data: AgentUpdateInput!) {
updateAgent(id: $id, data: $data) { id workspaceMemberId }
}`,
{ id: seatId, data: { workspaceMemberId } },
);
await fetchData();
// Mark the step complete on first successful action so
// the wizard can advance. Subsequent edits don't re-mark.
if (!props.isCompleted) {
await props.onComplete('telephony');
}
// Clear editor selection so the admin starts the next
// assign from scratch.
setSelectedSeatId('');
setSelectedMemberId('');
} catch (err) {
console.error('[wizard/telephony] updateAgent failed', err);
} finally {
setSaving(false);
}
};
const handleAssign = () => {
if (!selectedSeatId || !selectedMemberId) {
notify.error('Pick a seat and a member to assign');
return;
}
updateSeat(selectedSeatId, selectedMemberId);
};
const handleUnassign = () => {
if (!selectedSeatId) return;
updateSeat(selectedSeatId, null);
};
const pretendCompleted = props.isCompleted || agents.some((a) => a.workspaceMemberId !== null);
return (
<WizardStep
step="telephony"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={async () => {
if (!props.isCompleted) {
await props.onComplete('telephony');
}
props.onAdvance();
}}
onFinish={props.onFinish}
saving={saving}
rightPane={<TelephonyRightPane seats={seatSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading SIP seats</p>
) : agents.length === 0 ? (
<div className="rounded-lg border border-secondary bg-secondary p-6 text-sm text-tertiary">
<p className="font-medium text-primary">No SIP seats configured</p>
<p className="mt-1">
This hospital has no pre-provisioned agent profiles. Contact support to
add SIP seats, then come back to finish setup.
</p>
</div>
) : (
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
<p>
Pick a SIP seat and assign it to a workspace member. To free up a seat,
select it and click <b>Unassign</b>. The right pane shows the live
mapping what you change here updates there immediately.
</p>
</div>
<Select
label="SIP seat"
placeholder="Select a seat"
items={seatItems}
selectedKey={selectedSeatId || null}
onSelectionChange={(key) => setSelectedSeatId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<Select
label="Workspace member"
placeholder={
!selectedSeatId
? 'Pick a seat first'
: memberItems.length === 0
? 'No available members'
: 'Select a member'
}
isDisabled={!selectedSeatId || memberItems.length === 0}
items={memberItems}
selectedKey={selectedMemberId || null}
onSelectionChange={(key) => setSelectedMemberId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<div className="flex items-center justify-end gap-3">
{isCurrentlyMapped && (
<Button
color="secondary-destructive"
size="md"
isDisabled={saving}
onClick={handleUnassign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
)}
>
Unassign
</Button>
)}
<Button
color="primary"
size="md"
isDisabled={saving || !selectedSeatId || !selectedMemberId}
onClick={handleAssign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faHeadset} className={className} />
)}
>
{selectedSeat?.workspaceMemberId === selectedMemberId
? 'Already assigned'
: isCurrentlyMapped
? 'Reassign'
: 'Assign'}
</Button>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -0,0 +1,20 @@
import type { SetupStepName } from '@/lib/setup-state';
// Shared prop shape for every wizard step. The parent (setup-wizard.tsx)
// dispatches to the right component based on activeStep; each component
// handles its own data loading, form state, and save action, then calls
// onComplete + onAdvance when the user clicks "Mark complete".
export type WizardStepComponentProps = {
isCompleted: boolean;
isLast: boolean;
onPrev: (() => void) | null;
onNext: (() => void) | null;
// Called by each step after a successful save. Parent handles both the
// markSetupStepComplete API call AND the local state update so the left
// nav reflects the new completion immediately.
onComplete: (step: SetupStepName) => Promise<void>;
// Move to the next step (used after a successful save, or directly via
// the Next button when the step is already complete).
onAdvance: () => void;
onFinish: () => void;
};

View File

@@ -0,0 +1,142 @@
import { useContext, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowRight, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { SETUP_STEP_LABELS, type SetupStepName } from '@/lib/setup-state';
import { WizardLayoutContext } from './wizard-layout-context';
type WizardStepProps = {
step: SetupStepName;
isCompleted: boolean;
isLast: boolean;
onPrev: (() => void) | null;
onNext: (() => void) | null;
onMarkComplete: () => void;
onFinish: () => void;
saving?: boolean;
children: ReactNode;
// Optional content for the wizard shell's right preview pane.
// Portaled into the shell's <aside> via WizardLayoutContext when
// both are mounted. Each step component declares this inline so
// the per-step data fetching stays in one place.
rightPane?: ReactNode;
};
// Single-step wrapper. The parent picks which step is active and supplies
// the form content as children. The step provides title, description,
// "mark complete" CTA, and prev/next/finish navigation. In Phase 5 the
// children will be real form components from the corresponding settings
// pages — for now they're placeholders.
export const WizardStep = ({
step,
isCompleted,
isLast,
onPrev,
onNext,
onMarkComplete,
onFinish,
saving = false,
children,
rightPane,
}: WizardStepProps) => {
const meta = SETUP_STEP_LABELS[step];
const { rightPaneEl } = useContext(WizardLayoutContext);
return (
<>
{rightPane && rightPaneEl && createPortal(rightPane, rightPaneEl)}
<div className="rounded-xl border border-secondary bg-primary p-8 shadow-xs">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-primary">{meta.title}</h2>
<p className="mt-1 text-sm text-tertiary">{meta.description}</p>
</div>
{isCompleted && (
<span className="inline-flex items-center gap-2 rounded-full bg-success-primary px-3 py-1 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faCircleCheck} className="size-3.5" />
Complete
</span>
)}
</div>
<div className="mb-8">{children}</div>
<div className="flex items-center justify-between gap-4 border-t border-secondary pt-6">
<Button
color="secondary"
size="md"
isDisabled={!onPrev}
onClick={onPrev ?? undefined}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowLeft} className={className} />
)}
>
Previous
</Button>
{/* One primary action at the bottom — never two
competing buttons. Previously the wizard showed
Mark complete + Next side-by-side, and users
naturally clicked Next (rightmost = "continue"),
skipping the save+complete chain entirely. Result
was every step staying at 0/6.
New behaviour: a single button whose label and
handler depend on completion state.
- !isCompleted, not last → "Save and continue"
calls onMarkComplete (which does save +
complete + advance via the step component's
handleSave). Forces the agent through the
completion path.
- !isCompleted, last → "Save and finish"
same chain, plus onFinish at the end.
- isCompleted, not last → "Continue"
calls onNext (pure navigation).
- isCompleted, last → "Finish setup"
calls onFinish.
Free-form navigation is still available via the
left-side step nav, so users can revisit completed
steps without re-saving. */}
<div className="flex items-center gap-3">
{!isCompleted ? (
<Button
color="primary"
size="md"
isLoading={saving}
showTextWhileLoading
onClick={onMarkComplete}
iconTrailing={
isLast
? undefined
: ({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)
}
>
{isLast ? 'Save and finish' : 'Save and continue'}
</Button>
) : isLast ? (
<Button color="primary" size="md" onClick={onFinish}>
Finish setup
</Button>
) : (
<Button
color="primary"
size="md"
isDisabled={!onNext}
onClick={onNext ?? undefined}
iconTrailing={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)}
>
Continue
</Button>
)}
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { cx } from '@/utils/cx';
export const AiFloatingButton = () => {
const [open, setOpen] = useState(false);
return (
<>
{/* FAB — bottom right, hidden when drawer is open */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex size-12 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg hover:bg-brand-solid_hover transition duration-100 ease-linear"
title="AI Assistant"
>
<FontAwesomeIcon icon={faSparkles} className="size-5" />
</button>
)}
{/* Drawer — slides in from right */}
<div className={cx(
'fixed top-0 right-0 z-50 h-full bg-primary border-l border-secondary shadow-xl transition-all duration-200 ease-linear flex flex-col',
open ? 'w-[400px]' : 'w-0 overflow-hidden border-l-0',
)}>
{open && (
<>
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-4 py-3">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-sm font-semibold text-primary">AI Assistant</span>
</div>
<button
onClick={() => setOpen(false)}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
</>
)}
</div>
</>
);
};

View File

@@ -2,11 +2,13 @@ import { useState, useEffect, useRef } from 'react';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline'; export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
export type SupervisorPresence = 'none' | 'whisper' | 'barge';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const useAgentState = (agentId: string | null): OzonetelState => { export const useAgentState = (agentId: string | null): { state: OzonetelState; supervisorPresence: SupervisorPresence } => {
const [state, setState] = useState<OzonetelState>('offline'); const [state, setState] = useState<OzonetelState>('offline');
const [supervisorPresence, setSupervisorPresence] = useState<SupervisorPresence>('none');
const prevStateRef = useRef<OzonetelState>('offline'); const prevStateRef = useRef<OzonetelState>('offline');
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
@@ -50,12 +52,26 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
localStorage.removeItem('helix_agent_config'); localStorage.removeItem('helix_agent_config');
localStorage.removeItem('helix_user'); localStorage.removeItem('helix_user');
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {}); import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip(false, 'agent-state-offline')).catch(() => {});
setTimeout(() => { window.location.href = '/login'; }, 1500); setTimeout(() => { window.location.href = '/login'; }, 1500);
return; return;
} }
// Supervisor presence events — don't replace agent state
if (data.state === 'supervisor-whisper') {
setSupervisorPresence('whisper');
return;
}
if (data.state === 'supervisor-barge') {
setSupervisorPresence('barge');
return;
}
if (data.state === 'supervisor-left') {
setSupervisorPresence('none');
return;
}
prevStateRef.current = data.state; prevStateRef.current = data.state;
setState(data.state); setState(data.state);
} catch { } catch {
@@ -74,5 +90,5 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
}; };
}, [agentId]); }, [agentId]);
return state; return { state, supervisorPresence };
}; };

View File

@@ -5,6 +5,7 @@ import { useData } from '@/providers/data-provider';
type UseLeadsFilters = { type UseLeadsFilters = {
source?: LeadSource; source?: LeadSource;
excludeSources?: Set<LeadSource>;
status?: LeadStatus; status?: LeadStatus;
search?: string; search?: string;
}; };
@@ -17,7 +18,7 @@ type UseLeadsResult = {
export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => { export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
const { leads, updateLead } = useData(); const { leads, updateLead } = useData();
const { source, status, search } = filters; const { source, excludeSources, status, search } = filters;
const filteredLeads = useMemo(() => { const filteredLeads = useMemo(() => {
return leads.filter((lead) => { return leads.filter((lead) => {
@@ -25,6 +26,10 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
return false; return false;
} }
if (excludeSources && lead.leadSource && excludeSources.has(lead.leadSource)) {
return false;
}
if (status !== undefined && lead.leadStatus !== status) { if (status !== undefined && lead.leadStatus !== status) {
return false; return false;
} }
@@ -46,7 +51,7 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
return true; return true;
}); });
}, [leads, source, status, search]); }, [leads, source, excludeSources, status, search]);
return { return {
leads: filteredLeads, leads: filteredLeads,

View File

@@ -5,6 +5,10 @@ export type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
// When set, after OTP passes the modal calls this endpoint to fetch
// `{ locked, free }` agent buckets and shows a picker. Confirm then
// POSTs to `endpoint` with { agentId } from the selection.
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
endpoint: 'force-ready', endpoint: 'force-ready',
label: 'Force Ready', label: 'Force Ready',
description: 'Logout and re-login the agent to force Ready state on Ozonetel.', description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
agentPickerEndpoint: 'session-status',
}, },
unlockAgent: { unlockAgent: {
endpoint: 'unlock-agent', endpoint: 'unlock-agent',
label: 'Unlock Agent', label: 'Unlock Agent',
description: 'Release the Redis session lock so the agent can log in again.', description: 'Release the Redis session lock so the agent can log in again.',
agentPickerEndpoint: 'session-status',
}, },
backfill: { backfill: {
endpoint: 'backfill-missed-calls', endpoint: 'backfill-missed-calls',

View File

@@ -1,102 +1,101 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
export type PerformanceAlert = { export type PerformanceAlert = {
id: string; id: string;
agent: string; agent: string;
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion'; agentId: string | null;
type: string;
value: string; value: string;
severity: 'error' | 'warning'; severity: 'error' | 'warning' | 'info';
message?: string | null;
firedAt?: string;
dismissed: boolean; dismissed: boolean;
}; };
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const POLL_INTERVAL_MS = 60_000;
const sevToFront = (s: string): 'error' | 'warning' | 'info' => {
const v = (s ?? '').toLowerCase();
if (v === 'critical') return 'error';
if (v === 'warning') return 'warning';
return 'info';
};
export const usePerformanceAlerts = () => { export const usePerformanceAlerts = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { calls, leads } = useData();
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]); const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
const [teamPerf, setTeamPerf] = useState<any>(null); const lastSeenIdsRef = useRef<Set<string>>(new Set());
const toastsFiredRef = useRef(false);
// Fetch team performance data from sidecar (same as team-performance page) const load = useCallback(async () => {
useEffect(() => {
if (!isAdmin) return; if (!isAdmin) return;
const today = new Date().toISOString().split('T')[0];
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, { try {
headers: { Authorization: `Bearer ${token}` }, const res = await fetch(`${API_URL}/api/supervisor/performance-alerts`, {
}) headers: { Authorization: `Bearer ${token}` },
.then(r => r.ok ? r.json() : null) });
.then(data => setTeamPerf(data)) if (!res.ok) return;
.catch(() => {}); const json = await res.json();
const list: PerformanceAlert[] = (json?.alerts ?? []).map((a: any) => ({
id: a.id,
agent: a.agent,
agentId: a.agentId ?? null,
type: a.type,
value: a.value ?? '',
severity: sevToFront(a.severity),
message: a.message,
firedAt: a.firedAt,
dismissed: false,
}));
setAlerts(list);
// Toast for newly arrived alerts
const fresh = list.filter((a) => !lastSeenIdsRef.current.has(a.id));
if (fresh.length > 0 && lastSeenIdsRef.current.size > 0) {
notify.error('Performance Alerts', `${fresh.length} new alert(s)`);
}
lastSeenIdsRef.current = new Set(list.map((a) => a.id));
} catch {
// Silent — sidecar may be temporarily down
}
}, [isAdmin]); }, [isAdmin]);
// Compute alerts from team performance + entity data
useMemo(() => {
if (!isAdmin || !teamPerf?.agents) return;
const parseTime = (t: string): number => {
const parts = t.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
};
const list: PerformanceAlert[] = [];
let idx = 0;
for (const agent of teamPerf.agents) {
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const totalCalls = agentCalls.length;
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
const tb = agent.timeBreakdown;
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
}
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
}
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
}
}
setAlerts(list);
}, [isAdmin, teamPerf, calls, leads]);
// Fire toasts once when alerts first load
useEffect(() => { useEffect(() => {
if (toastsFiredRef.current || alerts.length === 0) return; if (!isAdmin) return;
toastsFiredRef.current = true; load();
const id = setInterval(load, POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [isAdmin, load]);
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length; const dismiss = useCallback(async (id: string) => {
const npsCount = alerts.filter(a => a.type === 'Low NPS').length; // Optimistic
const convCount = alerts.filter(a => a.type === 'Low Conversion').length; setAlerts((prev) => prev.filter((a) => a.id !== id));
const token = localStorage.getItem('helix_access_token') ?? '';
const parts: string[] = []; try {
if (idleCount > 0) parts.push(`${idleCount} excessive idle`); await fetch(`${API_URL}/api/supervisor/performance-alerts/${id}/dismiss`, {
if (npsCount > 0) parts.push(`${npsCount} low NPS`); method: 'POST',
if (convCount > 0) parts.push(`${convCount} low conversion`); headers: { Authorization: `Bearer ${token}` },
});
if (parts.length > 0) { } catch {
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`); // Reload on failure to restore truth
load();
} }
}, [alerts]); }, [load]);
const dismiss = (id: string) => { const dismissAll = useCallback(async () => {
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a)); setAlerts([]);
}; const token = localStorage.getItem('helix_access_token') ?? '';
try {
await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch {
load();
}
}, [load]);
const dismissAll = () => { return { alerts, allAlerts: alerts, dismiss, dismissAll };
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
};
const activeAlerts = alerts.filter(a => !a.dismissed);
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
}; };

50
src/hooks/use-ui-flags.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
// Per-tenant UI flags the sidecar controls via env vars. Read once at
// app mount; cached in module scope so every consumer gets the same
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
// respond (all flags off) so the UI stays functional.
export type UiFlags = {
setupManaged: boolean;
};
const DEFAULT_FLAGS: UiFlags = {
setupManaged: false,
};
let cachedFlags: UiFlags | null = null;
let inflight: Promise<UiFlags> | null = null;
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
const fetchFlags = (): Promise<UiFlags> => {
if (cachedFlags) return Promise.resolve(cachedFlags);
if (inflight) return inflight;
inflight = apiClient
.get<UiFlags>('/api/config/ui-flags', { silent: true })
.then((res) => {
cachedFlags = { ...DEFAULT_FLAGS, ...res };
return cachedFlags;
})
.catch(() => {
cachedFlags = { ...DEFAULT_FLAGS };
return cachedFlags;
})
.finally(() => {
inflight = null;
});
return inflight;
};
export const useUiFlags = (): UiFlags => {
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
useEffect(() => {
if (cachedFlags) {
setFlags(cachedFlags);
return;
}
fetchFlags().then(setFlags);
}, []);
return flags;
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type MissedCall = { type MissedCall = {
id: string; id: string;
@@ -15,10 +16,11 @@ type MissedCall = {
disposition: string | null; disposition: string | null;
callNotes: string | null; callNotes: string | null;
leadId: string | null; leadId: string | null;
callbackstatus: string | null; leadName: string | null;
callsourcenumber: string | null; callbackStatus: string | null;
missedcallcount: number | null; callSourceNumber: string | null;
callbackattemptedat: string | null; missedCallCount: number | null;
callbackAttemptedAt: string | null;
}; };
type WorklistFollowUp = { type WorklistFollowUp = {
@@ -32,6 +34,8 @@ type WorklistFollowUp = {
assignedAgent: string | null; assignedAgent: string | null;
patientId: string | null; patientId: string | null;
callId: string | null; callId: string | null;
patientName?: string;
patientPhone?: string;
}; };
type WorklistLead = { type WorklistLead = {
@@ -130,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => {
useEffect(() => { useEffect(() => {
fetchWorklist(); fetchWorklist();
// Refresh every 30 seconds // SSE stream for instant worklist updates. No polling fallback —
const interval = setInterval(fetchWorklist, 30000); // if SSE breaks, the worklist stops updating and we fix the SSE,
return () => clearInterval(interval); // not paper over it with a poll.
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
let es: EventSource | null = null;
try {
es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[WORKLIST-SSE]', data);
fetchWorklist();
if (data.type === 'missed-call') {
const name = data.callerName ?? data.callerPhone ?? 'Unknown';
notify.warning('Missed Call', `${name} — needs callback`);
}
} catch {}
};
es.onerror = () => {
console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect');
};
} catch {}
return () => { es?.close(); };
}, [fetchWorklist]); }, [fetchWorklist]);
return { ...data, loading, error, refresh: fetchWorklist }; return { ...data, loading, error, refresh: fetchWorklist };

View File

@@ -1,6 +1,9 @@
import { notify } from './toast'; import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; // In production, use the current origin — Caddy routes /api/* to the
// correct per-tenant sidecar based on hostname. Only use VITE_API_URL
// for local dev (pointing to a specific sidecar).
const API_URL = import.meta.env.VITE_API_URL || window.location.origin;
class AuthError extends Error { class AuthError extends Error {
constructor(message = 'Authentication required') { constructor(message = 'Authentication required') {
@@ -212,6 +215,16 @@ export const apiClient = {
return handleResponse<T>(response, options?.silent, doFetch); return handleResponse<T>(response, options?.silent, doFetch);
}, },
async put<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'PUT',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
},
// Health check — silent, no toasts // Health check — silent, no toasts
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> { async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
try { try {

View File

@@ -1,5 +1,36 @@
export type CSVRow = Record<string, string>; export type CSVRow = Record<string, string>;
// CSV write-side. Quote every value and escape embedded quotes. Prefix
// ="+-@ with a single quote so Excel doesn't interpret them as formulas
// (classic CSV-injection vector on exports opened in spreadsheet apps).
const escapeCsvCell = (raw: unknown): string => {
const value = raw == null ? '' : String(raw);
const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value;
return `"${sanitized.replace(/"/g, '""')}"`;
};
export const rowsToCsv = (headers: string[], rows: Array<Record<string, unknown>>): string => {
const lines = [headers.map(escapeCsvCell).join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvCell(row[h])).join(','));
}
return lines.join('\r\n');
};
export const downloadCsv = (filename: string, csv: string): void => {
// BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses.
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export type CSVParseResult = { export type CSVParseResult = {
headers: string[]; headers: string[];
rows: CSVRow[]; rows: CSVRow[];

View File

@@ -1,7 +1,21 @@
// GraphQL queries for platform entities // GraphQL queries for platform entities
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection // Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
//
// Each entity exports a query *builder* that accepts an optional `after`
// cursor. The data-provider paginates until `hasNextPage=false` so the
// dashboard KPIs reflect real totals instead of the first 100 rows. The
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
// quietly plateau at 100 on busy tenants.
//
// `pageSize` is intentionally large (200) to keep round-trips low. The
// platform Relay pagination accepts up to 1000 but 200 is a good balance
// between latency per page and number of pages on active workspaces.
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { const PAGE_SIZE = 200;
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
contactName { firstName lastName } contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode } contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
@@ -12,63 +26,71 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
firstContacted lastContacted contactAttempts convertedAt firstContacted lastContacted contactAttempts convertedAt
patientId campaignId patientId campaignId
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50) { edges { node { export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id id name createdAt updatedAt
name campaignName typeCustom status platform
createdAt startDate endDate
updatedAt
status
typeCustom
platform
startDate
endDate
budget { amountMicros currencyCode } budget { amountMicros currencyCode }
amountSpent { amountMicros currencyCode } amountSpent { amountMicros currencyCode }
impressions impressions clicks targetCount contacted converted leadsGenerated
clicks externalCampaignId platformUrl { primaryLinkUrl }
targetCount } } pageInfo { hasNextPage endCursor } } }`;
contacted
converted
leadsGenerated
externalCampaignId
platformUrl { primaryLinkUrl }
} } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
adName externalAdId status format adName externalAdId status format
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl } headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
impressions clicks conversions impressions clicks conversions
spend { amountMicros currencyCode } spend { amountMicros currencyCode }
campaignId campaignId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
typeCustom status scheduledAt completedAt typeCustom status scheduledAt completedAt
priority assignedAgent priority assignedAgent
patientId patientId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
activityType summary occurredAt performedBy activityType summary occurredAt performedBy
previousValue newValue previousValue newValue
channel durationSec outcome channel durationSec outcome
leadId leadId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
direction callStatus callerNumber { primaryPhoneNumber } agentName direction callStatus callerNumber { primaryPhoneNumber } agentName
startedAt endedAt durationSec startedAt endedAt durationSec
recording { primaryLinkUrl } disposition sla recording { primaryLinkUrl } disposition sla
patientId appointmentId leadId patientId appointmentId leadId
} } } }`; agentId agent { id name ozonetelAgentId }
transferredTo transferType
} } pageInfo { hasNextPage endCursor } } }`;
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } pageInfo { hasNextPage endCursor } } }`;
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } pageInfo { hasNextPage endCursor } } }`;
// Doctors are a small reference set (< 50 per workspace) — no pagination
// needed. Left as a plain string for the single consumer that reads it.
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience department specialty qualifications yearsOfExperience
visitingHours visitingHours
@@ -77,18 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
active registrationNumber active registrationNumber
clinic { id name clinicName } clinic { id name clinicName }
} } } }`; } } } }`;
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id clinic { clinicName } }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

83
src/lib/setup-state.ts Normal file
View File

@@ -0,0 +1,83 @@
import { apiClient } from './api-client';
// Mirror of the sidecar SetupState shape — keep in sync with
// helix-engage-server/src/config/setup-state.defaults.ts. Any change to the
// step list there must be reflected here.
export type SetupStepName =
| 'identity'
| 'clinics'
| 'doctors'
| 'team'
| 'telephony'
| 'ai';
export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
'identity',
'clinics',
'doctors',
'team',
'telephony',
'ai',
] as const;
export type SetupStepStatus = {
completed: boolean;
completedAt: string | null;
completedBy: string | null;
};
export type SetupState = {
version?: number;
updatedAt?: string;
wizardDismissed: boolean;
steps: Record<SetupStepName, SetupStepStatus>;
wizardRequired: boolean;
};
// Human-friendly labels for the wizard UI + settings hub badges. Kept here
// next to the type so adding a new step touches one file.
export const SETUP_STEP_LABELS: Record<SetupStepName, { title: string; description: string }> = {
identity: {
title: 'Hospital Identity',
description: 'Confirm your hospital name, upload your logo, and pick brand colors.',
},
clinics: {
title: 'Clinics',
description: 'Add your physical branches with addresses and visiting hours.',
},
doctors: {
title: 'Doctors',
description: 'Add clinicians, assign them to clinics, and set their schedules.',
},
team: {
title: 'Team',
description: 'Invite supervisors and call-center agents to your workspace.',
},
telephony: {
title: 'Telephony',
description: 'Connect Ozonetel and Exotel for inbound and outbound calls.',
},
ai: {
title: 'AI Assistant',
description: 'Choose your AI provider and customise the assistant prompts.',
},
};
export const getSetupState = () =>
apiClient.get<SetupState>('/api/config/setup-state', { silent: true });
export const markSetupStepComplete = (step: SetupStepName, completedBy?: string) =>
apiClient.put<SetupState>(`/api/config/setup-state/steps/${step}`, {
completed: true,
completedBy,
});
export const markSetupStepIncomplete = (step: SetupStepName) =>
apiClient.put<SetupState>(`/api/config/setup-state/steps/${step}`, { completed: false });
export const dismissSetupWizard = () =>
apiClient.post<SetupState>('/api/config/setup-state/dismiss');
export const resetSetupState = () =>
apiClient.post<SetupState>('/api/config/setup-state/reset');

View File

@@ -7,6 +7,10 @@ export class SIPClient {
private ua: JsSIP.UA | null = null; private ua: JsSIP.UA | null = null;
private currentSession: RTCSession | null = null; private currentSession: RTCSession | null = null;
private audioElement: HTMLAudioElement | null = null; private audioElement: HTMLAudioElement | null = null;
// Watchdog that alerts if REGISTER never completes after a connect.
// Cleared on 'registered' / 'registrationFailed' / disconnect.
private registrationWatchdog: number | null = null;
private readonly REGISTRATION_TIMEOUT_MS = 15_000;
constructor( constructor(
private config: SIPConfig, private config: SIPConfig,
@@ -36,28 +40,43 @@ export class SIPClient {
this.ua = new JsSIP.UA(configuration); this.ua = new JsSIP.UA(configuration);
console.log(`[SIP] start() uri=${this.config.uri} ws=${this.config.wsServer} expires=${configuration.register_expires}s`);
this.ua.on('connecting', () => {
console.log('[SIP] WebSocket connecting…');
});
this.ua.on('connected', () => { this.ua.on('connected', () => {
console.log('[SIP] WebSocket connected'); console.log('[SIP] WebSocket connected — waiting for REGISTER');
this.onConnectionChange('connected'); this.onConnectionChange('connected');
}); });
this.ua.on('disconnected', () => { this.ua.on('disconnected', (e: any) => {
console.log('[SIP] WebSocket disconnected'); const code = e?.code ?? 'n/a';
const reason = e?.reason ?? 'unknown';
console.log(`[SIP] WebSocket disconnected — code=${code} reason=${reason}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected'); this.onConnectionChange('disconnected');
}); });
this.ua.on('registered', () => { this.ua.on('registered', () => {
console.log('[SIP] Registered successfully'); console.log('[SIP] Registered successfully');
this.clearRegistrationWatchdog();
this.onConnectionChange('registered'); this.onConnectionChange('registered');
}); });
this.ua.on('unregistered', () => { this.ua.on('unregistered', () => {
console.log('[SIP] Unregistered'); console.log('[SIP] Unregistered');
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected'); this.onConnectionChange('disconnected');
}); });
this.ua.on('registrationFailed', () => { this.ua.on('registrationFailed', (e: any) => {
console.error('[SIP] Registration failed'); const cause = e?.cause ?? 'unknown';
const statusCode = e?.response?.status_code ?? 'n/a';
const reasonPhrase = e?.response?.reason_phrase ?? '';
console.error(`[SIP] Registration failed — cause=${cause} status=${statusCode} ${reasonPhrase}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('error'); this.onConnectionChange('error');
}); });
@@ -125,9 +144,25 @@ export class SIPClient {
}); });
this.ua.start(); this.ua.start();
// Arm the registration watchdog. If we don't hear 'registered' or
// 'registrationFailed' within the timeout, surface a visible error so
// the user isn't left staring at "Connecting to telephony…" forever.
this.registrationWatchdog = window.setTimeout(() => {
console.error(`[SIP] Registration timeout — no REGISTER response after ${this.REGISTRATION_TIMEOUT_MS}ms. Check SIP credentials / WebSocket reachability.`);
this.onConnectionChange('error');
}, this.REGISTRATION_TIMEOUT_MS);
}
private clearRegistrationWatchdog(): void {
if (this.registrationWatchdog !== null) {
window.clearTimeout(this.registrationWatchdog);
this.registrationWatchdog = null;
}
} }
disconnect(): void { disconnect(): void {
this.clearRegistrationWatchdog();
this.hangup(); this.hangup();
if (this.ua) { if (this.ua) {
this.ua.stop(); this.ua.stop();

View File

@@ -0,0 +1,196 @@
import JsSIP from 'jssip';
type RTCSession = any;
// Lightweight SIP client for supervisor barge sessions.
// Separate from the agent's sip-client.ts — different lifecycle.
// Modeled on Ozonetel's kSip utility (CA-Admin/.../utils/ksip.tsx).
//
// DTMF mode mapping (from Ozonetel CA-Admin BargeinDrawerSip.tsx):
// "4" → Listen (supervisor hears all, nobody hears supervisor)
// "5" → Whisper/Training (agent hears supervisor, patient doesn't)
// "6" → Barge (both hear supervisor)
type EventCallback = (...args: any[]) => void;
type SupervisorSipEvent =
| 'registered'
| 'registrationFailed'
| 'callReceived'
| 'callConnected'
| 'callEnded'
| 'callFailed';
type SupervisorSipConfig = {
domain: string;
port: string;
number: string;
password: string;
};
class SupervisorSipClient {
private ua: JsSIP.UA | null = null;
private session: RTCSession | null = null;
private listeners = new Map<string, Set<EventCallback>>();
private audioElement: HTMLAudioElement | null = null;
init(config: SupervisorSipConfig): void {
this.cleanup();
// Hidden audio element for remote call audio
this.audioElement = document.createElement('audio');
this.audioElement.id = 'supervisor-remote-audio';
this.audioElement.autoplay = true;
this.audioElement.setAttribute('playsinline', '');
document.body.appendChild(this.audioElement);
const socketUrl = `wss://${config.domain}:${config.port}`;
const socket = new JsSIP.WebSocketInterface(socketUrl);
this.ua = new JsSIP.UA({
sockets: [socket],
uri: `sip:${config.number}@${config.domain}`,
password: config.password,
registrar_server: `sip:${config.domain}`,
authorization_user: config.number,
session_timers: false,
register: false,
});
this.ua.on('registered', () => {
console.log('[SupervisorSIP] Registered');
this.emit('registered');
});
this.ua.on('registrationFailed', (e: any) => {
console.error('[SupervisorSIP] Registration failed:', e?.cause);
this.emit('registrationFailed', e?.cause);
});
this.ua.on('newRTCSession', (data: any) => {
const rtcSession = data.session as RTCSession;
if (rtcSession.direction !== 'incoming') return;
console.log('[SupervisorSIP] Incoming call — auto-answering');
this.session = rtcSession;
this.emit('callReceived');
rtcSession.on('accepted', () => {
console.log('[SupervisorSIP] Call accepted');
this.emit('callConnected');
});
rtcSession.on('confirmed', () => {
// Attach remote audio stream
const connection = rtcSession.connection;
if (connection && this.audioElement) {
// Modern browsers: track event
connection.addEventListener('track', (event: RTCTrackEvent) => {
if (event.streams[0] && this.audioElement) {
this.audioElement.srcObject = event.streams[0];
}
});
// Fallback: getRemoteStreams (older browsers/JsSIP versions)
const remoteStreams = (connection as any).getRemoteStreams?.();
if (remoteStreams?.[0] && this.audioElement) {
this.audioElement.srcObject = remoteStreams[0];
}
}
});
rtcSession.on('ended', () => {
console.log('[SupervisorSIP] Call ended');
this.session = null;
this.emit('callEnded');
});
rtcSession.on('failed', (e: any) => {
console.error('[SupervisorSIP] Call failed:', e?.cause);
this.session = null;
this.emit('callFailed', e?.cause);
});
// Auto-answer with audio
rtcSession.answer({
mediaConstraints: { audio: true, video: false },
});
});
this.ua.start();
}
register(): void {
this.ua?.register();
}
isRegistered(): boolean {
return this.ua?.isRegistered() ?? false;
}
isCallActive(): boolean {
return this.session?.isEstablished() ?? false;
}
sendDTMF(digit: string): void {
if (!this.session?.isEstablished()) {
console.warn('[SupervisorSIP] Cannot send DTMF — no active session');
return;
}
console.log(`[SupervisorSIP] Sending DTMF: ${digit}`);
this.session.sendDTMF(digit, {
duration: 160,
interToneGap: 1200,
});
}
hangup(): void {
if (this.session) {
try {
this.session.terminate();
} catch {
// Session may already be ended
}
this.session = null;
}
}
close(): void {
this.hangup();
if (this.ua) {
try {
this.ua.unregister({ all: true });
this.ua.stop();
} catch {
// UA may already be stopped
}
this.ua = null;
}
this.cleanup();
this.listeners.clear();
}
on(event: SupervisorSipEvent, callback: EventCallback): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
off(event: SupervisorSipEvent, callback: EventCallback): void {
this.listeners.get(event)?.delete(callback);
}
private emit(event: string, ...args: any[]): void {
this.listeners.get(event)?.forEach(cb => {
try { cb(...args); } catch (e) { console.error(`[SupervisorSIP] Event error [${event}]:`, e); }
});
}
private cleanup(): void {
if (this.audioElement) {
this.audioElement.srcObject = null;
this.audioElement.remove();
this.audioElement = null;
}
}
}
export const supervisorSip = new SupervisorSipClient();

View File

@@ -53,14 +53,14 @@ export function transformLeads(data: any): Lead[] {
export function transformCampaigns(data: any): Campaign[] { export function transformCampaigns(data: any): Campaign[] {
return extractEdges(data, 'campaigns').map((n) => ({ return extractEdges(data, 'campaigns').map((n) => ({
id: n.id, id: n.id,
createdAt: n.createdAt ?? null, createdAt: n.createdAt,
updatedAt: n.updatedAt ?? null, updatedAt: n.updatedAt,
campaignName: n.name ?? 'Untitled Campaign', campaignName: n.campaignName ?? n.name,
campaignType: n.typeCustom ?? null, campaignType: n.typeCustom,
campaignStatus: n.status ?? 'ACTIVE', campaignStatus: n.status,
platform: n.platform ?? null, platform: n.platform,
startDate: n.startDate ?? null, startDate: n.startDate,
endDate: n.endDate ?? null, endDate: n.endDate,
budget: n.budget ? { amountMicros: n.budget.amountMicros, currencyCode: n.budget.currencyCode } : null, budget: n.budget ? { amountMicros: n.budget.amountMicros, currencyCode: n.budget.currencyCode } : null,
amountSpent: n.amountSpent ? { amountMicros: n.amountSpent.amountMicros, currencyCode: n.amountSpent.currencyCode } : null, amountSpent: n.amountSpent ? { amountMicros: n.amountSpent.amountMicros, currencyCode: n.amountSpent.currencyCode } : null,
impressionCount: n.impressions ?? 0, impressionCount: n.impressions ?? 0,
@@ -69,7 +69,7 @@ export function transformCampaigns(data: any): Campaign[] {
contactedCount: n.contacted ?? 0, contactedCount: n.contacted ?? 0,
convertedCount: n.converted ?? 0, convertedCount: n.converted ?? 0,
leadCount: n.leadsGenerated ?? 0, leadCount: n.leadsGenerated ?? 0,
externalCampaignId: n.externalCampaignId ?? null, externalCampaignId: n.externalCampaignId,
platformUrl: n.platformUrl?.primaryLinkUrl ?? null, platformUrl: n.platformUrl?.primaryLinkUrl ?? null,
})); }));
} }
@@ -150,26 +150,39 @@ export function transformCalls(data: any): Call[] {
patientId: n.patientId, patientId: n.patientId,
appointmentId: n.appointmentId, appointmentId: n.appointmentId,
leadId: n.leadId, leadId: n.leadId,
agentId: n.agentId ?? null,
agent: n.agent ?? null,
transferredTo: n.transferredTo ?? null,
transferType: n.transferType ?? null,
})); }));
} }
export function transformAppointments(data: any): Appointment[] { export function transformAppointments(data: any): Appointment[] {
return extractEdges(data, 'appointments').map((n) => ({ return extractEdges(data, 'appointments').map((n) => {
id: n.id, // Doctor name: prefer the relation's fullName (authoritative — pulled
createdAt: n.createdAt, // from the Doctor entity). Fall back to the denormalized doctorName
scheduledAt: n.scheduledAt, // field for legacy rows that predate the doctor relation being fetched.
durationMinutes: n.durationMin ?? 30, const doctorFullName = n.doctor?.fullName
appointmentType: n.appointmentType, ? `${n.doctor.fullName.firstName ?? ''} ${n.doctor.fullName.lastName ?? ''}`.trim()
appointmentStatus: n.status, : '';
doctorName: n.doctorName, return {
doctorId: n.doctor?.id ?? null, id: n.id,
department: n.department, createdAt: n.createdAt,
reasonForVisit: n.reasonForVisit, scheduledAt: n.scheduledAt,
patientId: n.patient?.id ?? null, durationMinutes: n.durationMin ?? 30,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null, appointmentType: n.appointmentType,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null, appointmentStatus: n.status,
clinicName: n.doctor?.clinic?.clinicName ?? null, doctorName: doctorFullName || n.doctorName || null,
})); doctorId: n.doctor?.id ?? null,
department: n.department,
reasonForVisit: n.reasonForVisit,
patientId: n.patient?.id ?? null,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
clinicId: n.clinicId ?? n.clinic?.id ?? null,
clinicName: n.clinic?.clinicName ?? null,
};
});
} }
export function transformPatients(data: any): Patient[] { export function transformPatients(data: any): Patient[] {

View File

@@ -1,8 +1,35 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter, Outlet, Route, Routes } from "react-router"; import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router";
import { AppShell } from "@/components/layout/app-shell"; import { AppShell } from "@/components/layout/app-shell";
import { AuthGuard } from "@/components/layout/auth-guard"; import { AuthGuard } from "@/components/layout/auth-guard";
import { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard";
import { useUiFlags } from "@/hooks/use-ui-flags";
const AdminSetupGuard = () => {
const { isAdmin } = useAuth();
const { setupManaged } = useUiFlags();
if (!isAdmin) return <Navigate to="/" replace />;
// When setup is managed by the product team for this tenant, there's
// nothing for an admin to do in the wizard — bounce them to the
// dashboard instead of rendering a dead-end page.
if (setupManaged) return <Navigate to="/" replace />;
return <SetupWizardPage />;
};
const RequireAdmin = () => {
const { isAdmin } = useAuth();
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
};
const RequireSelfServeSetup = () => {
const { setupManaged } = useUiFlags();
// Blocks /settings/* when the tenant's setup is product-team managed.
// Sidebar already hides the nav entry, but this catches stray bookmarks
// and deep links.
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
};
import { RoleRouter } from "@/components/layout/role-router"; import { RoleRouter } from "@/components/layout/role-router";
import { NotFound } from "@/pages/not-found"; import { NotFound } from "@/pages/not-found";
import { AllLeadsPage } from "@/pages/all-leads"; import { AllLeadsPage } from "@/pages/all-leads";
@@ -16,12 +43,14 @@ import { OutreachPage } from "@/pages/outreach";
import { Patient360Page } from "@/pages/patient-360"; import { Patient360Page } from "@/pages/patient-360";
import { ReportsPage } from "@/pages/reports"; import { ReportsPage } from "@/pages/reports";
import { PatientsPage } from "@/pages/patients"; import { PatientsPage } from "@/pages/patients";
import { ContactsPage } from "@/pages/contacts";
import { TeamDashboardPage } from "@/pages/team-dashboard"; import { TeamDashboardPage } from "@/pages/team-dashboard";
import { IntegrationsPage } from "@/pages/integrations"; import { IntegrationsPage } from "@/pages/integrations";
import { AgentDetailPage } from "@/pages/agent-detail"; import { AgentDetailPage } from "@/pages/agent-detail";
import { SettingsPage } from "@/pages/settings"; import { SettingsPage } from "@/pages/settings";
import { MyPerformancePage } from "@/pages/my-performance"; import { MyPerformancePage } from "@/pages/my-performance";
import { AppointmentsPage } from "@/pages/appointments"; // v2 appointments — testing locally via Tauri before replacing v1
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
import { TeamPerformancePage } from "@/pages/team-performance"; import { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor"; import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings"; import { CallRecordingsPage } from "@/pages/call-recordings";
@@ -30,6 +59,12 @@ import { ProfilePage } from "@/pages/profile";
import { AccountSettingsPage } from "@/pages/account-settings"; import { AccountSettingsPage } from "@/pages/account-settings";
import { RulesSettingsPage } from "@/pages/rules-settings"; import { RulesSettingsPage } from "@/pages/rules-settings";
import { BrandingSettingsPage } from "@/pages/branding-settings"; import { BrandingSettingsPage } from "@/pages/branding-settings";
import { TeamSettingsPage } from "@/pages/team-settings";
import { ClinicsPage } from "@/pages/clinics";
import { DoctorsPage } from "@/pages/doctors";
import { TelephonySettingsPage } from "@/pages/telephony-settings";
import { AiSettingsPage } from "@/pages/ai-settings";
import { WidgetSettingsPage } from "@/pages/widget-settings";
import { AuthProvider } from "@/providers/auth-provider"; import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider"; import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider"; import { RouteProvider } from "@/providers/router-provider";
@@ -49,6 +84,11 @@ createRoot(document.getElementById("root")!).render(
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<AuthGuard />}> <Route element={<AuthGuard />}>
{/* Setup wizard — admin only, fullscreen, no AppShell.
CC agents and other non-admin roles are redirected to
the call desk — they can't complete setup anyway. */}
<Route path="/setup" element={<AdminSetupGuard />} />
<Route <Route
element={ element={
<AppShell> <AppShell>
@@ -65,16 +105,29 @@ createRoot(document.getElementById("root")!).render(
<Route path="/call-history" element={<CallHistoryPage />} /> <Route path="/call-history" element={<CallHistoryPage />} />
<Route path="/my-performance" element={<MyPerformancePage />} /> <Route path="/my-performance" element={<MyPerformancePage />} />
<Route path="/call-desk" element={<CallDeskPage />} /> <Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/patients" element={<PatientsPage />} /> <Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} /> <Route path="/appointments" element={<AppointmentsPage />} />
<Route path="/team-performance" element={<TeamPerformancePage />} /> {/* Admin-only routes */}
<Route path="/live-monitor" element={<LiveMonitorPage />} /> <Route element={<RequireAdmin />}>
<Route path="/call-recordings" element={<CallRecordingsPage />} /> <Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/missed-calls" element={<MissedCallsPage />} /> <Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/team-dashboard" element={<TeamDashboardPage />} /> <Route path="/call-recordings" element={<CallRecordingsPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/missed-calls" element={<MissedCallsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} /> <Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} />
<Route element={<RequireSelfServeSetup />}>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} />
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
</Route>
</Route>
<Route path="/agent/:id" element={<AgentDetailPage />} /> <Route path="/agent/:id" element={<AgentDetailPage />} />
<Route path="/patient/:id" element={<Patient360Page />} /> <Route path="/patient/:id" element={<Patient360Page />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />

View File

@@ -65,11 +65,15 @@ const formatPhoneDisplay = (call: Call): string => {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' }, INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' }, NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' }, WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' }, CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
}; };
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => { const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
@@ -84,20 +88,39 @@ const DirectionIcon = ({ direction, status }: { direction: CallDirection | null;
export const AgentDetailPage = () => { export const AgentDetailPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { calls, leads, loading } = useData(); const { calls, leads, agents, loading } = useData();
const agentName = id ? decodeURIComponent(id) : ''; // Route param is either a platform Agent UUID (new bucketing) or
// "legacy:<rawAgentName>" for calls that haven't been enriched yet.
// Older bookmarks may still pass the raw display name — handle that too.
const rawId = id ? decodeURIComponent(id) : '';
const isLegacy = rawId.startsWith('legacy:');
const agentUuid = !isLegacy ? rawId : null;
const legacyName = isLegacy ? rawId.slice('legacy:'.length) : null;
// Resolve display name: prefer Agent entity name, else the legacy string.
const agentName = useMemo(() => {
if (agentUuid) {
const a = agents.find((x: any) => x.id === agentUuid);
return a?.name ?? rawId;
}
return legacyName ?? '';
}, [agentUuid, legacyName, agents, rawId]);
const agentCalls = useMemo( const agentCalls = useMemo(
() => () =>
calls calls
.filter((c) => c.agentName === agentName) .filter((c) => {
if (agentUuid) return c.agentId === agentUuid;
if (legacyName) return !c.agentId && c.agentName === legacyName;
return false;
})
.sort((a, b) => { .sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0; const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return dateB - dateA; return dateB - dateA;
}), }),
[calls, agentName], [calls, agentUuid, legacyName],
); );
// Build lead name map for enrichment // Build lead name map for enrichment

159
src/pages/ai-settings.tsx Normal file
View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRobot, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
AiForm,
emptyAiFormValues,
type AiFormValues,
type AiProvider,
} from '@/components/forms/ai-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/ai — Pattern B page for the AI assistant config. Backed by
// /api/config/ai which is file-backed (data/ai.json) and hot-reloaded through
// AiConfigService — no restart needed.
//
// Temperature is a string in the form for input UX (so users can partially
// type '0.', '0.5', etc) then clamped to 0..2 on save.
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
systemPromptAddendum?: string;
};
const clampTemperature = (raw: string): number => {
const n = Number(raw);
if (Number.isNaN(n)) return 0.7;
return Math.min(2, Math.max(0, n));
};
export const AiSettingsPage = () => {
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai');
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: data.systemPromptAddendum ?? '',
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
setIsSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: clampTemperature(values.temperature),
systemPromptAddendum: values.systemPromptAddendum,
});
notify.success('AI settings updated', 'Changes are live for new conversations.');
markSetupStepComplete('ai').catch(() => {});
await loadConfig();
} catch (err) {
console.error('[ai] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset AI settings to defaults? The system prompt addendum will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/ai/reset');
notify.info('AI reset', 'Provider, model, and prompt have been restored to defaults.');
await loadConfig();
} catch (err) {
console.error('[ai] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="AI Assistant" subtitle="Choose provider, model, and conversational guidelines" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">API keys live in environment variables</p>
<p className="text-xs text-tertiary">
The actual OPENAI_API_KEY and ANTHROPIC_API_KEY are set at deploy time and
can't be edited here. If you change the provider, make sure the matching key
is configured on the sidecar or the assistant will silently fall back to the
other provider.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading AI settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<AiForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -2,28 +2,32 @@ import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />; const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; // Tabs removed — campaign pills handle all filtering now
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { BulkActionBar } from '@/components/leads/bulk-action-bar'; // Bulk actions removed — checkboxes hidden
// import { BulkActionBar } from '@/components/leads/bulk-action-bar';
import { FilterPills } from '@/components/leads/filter-pills'; import { FilterPills } from '@/components/leads/filter-pills';
import { AssignModal } from '@/components/modals/assign-modal'; // Bulk action modals removed — checkboxes hidden
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal'; // import { AssignModal } from '@/components/modals/assign-modal';
import { MarkSpamModal } from '@/components/modals/mark-spam-modal'; // import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
// import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout'; import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useLeads } from '@/hooks/use-leads'; import { useLeads } from '@/hooks/use-leads';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
import { notify } from '@/lib/toast';
import type { Lead, LeadSource, LeadStatus } from '@/types/entities'; import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
type TabKey = 'new' | 'my-leads' | 'all'; type TabKey = 'new' | 'my-leads' | 'all';
@@ -40,24 +44,28 @@ export const AllLeadsPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialSource = searchParams.get('source') as LeadSource | null; const initialSource = searchParams.get('source') as LeadSource | null;
const [tab, setTab] = useState<TabKey>('new'); const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortField, setSortField] = useState('createdAt'); const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource); const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined; const statusFilter: LeadStatus | undefined = undefined;
const myLeadsOnly = tab === 'my-leads'; const myLeadsOnly = false;
const { leads: filteredLeads, total, updateLead } = useLeads({ // Exclude organic contact sources — those live on the Contacts page.
// Leads page shows campaign-sourced / marketing-qualified leads only.
const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []);
const { leads: filteredLeads, total } = useLeads({
source: sourceFilter ?? undefined, source: sourceFilter ?? undefined,
excludeSources: CONTACT_SOURCES,
status: statusFilter, status: statusFilter,
search: searchQuery || undefined, search: searchQuery || undefined,
}); });
const { agents, templates, leadActivities, campaigns } = useData(); const { leadActivities, campaigns } = useData();
const [campaignFilter, setCampaignFilter] = useState<string | null>(null); const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
const columnDefs = [ const columnDefs = [
@@ -138,9 +146,7 @@ export const AllLeadsPage = () => {
result = result.filter((l) => l.assignedAgent === user.name); result = result.filter((l) => l.assignedAgent === user.name);
} }
if (campaignFilter) { if (campaignFilter) {
result = campaignFilter === '__none__' result = result.filter((l) => l.campaignId === campaignFilter);
? result.filter((l) => !l.campaignId)
: result.filter((l) => l.campaignId === campaignFilter);
} }
return result; return result;
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]); }, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
@@ -159,15 +165,47 @@ export const AllLeadsPage = () => {
setCurrentPage(1); setCurrentPage(1);
}; };
const handleTabChange = (key: string | number) => {
setTab(key as TabKey); const handleExportCsv = () => {
setCurrentPage(1); // Export exactly what the user currently sees — same filters, same
setSelectedIds([]); // sort, same tab/campaign scope. Ignores pagination so the file
// contains every matching row, not just the current page.
if (displayLeads.length === 0) {
notify.error('Export CSV', 'No leads to export');
return;
}
const headers = [
'Phone', 'First Name', 'Last Name', 'Email',
'Source', 'Status', 'Campaign', 'Assigned Agent',
'First Contact', 'Last Contact', 'Created', 'Age (days)',
];
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
const now = Date.now();
const rows = displayLeads.map((l) => {
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
return {
'Phone': l.contactPhone?.[0]?.number ?? '',
'First Name': l.contactName?.firstName ?? '',
'Last Name': l.contactName?.lastName ?? '',
'Email': l.contactEmail?.[0]?.address ?? '',
'Source': l.leadSource ?? '',
'Status': l.leadStatus ?? '',
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
'Assigned Agent': l.assignedAgent ?? '',
'First Contact': l.firstContactedAt ?? '',
'Last Contact': l.lastContactedAt ?? '',
'Created': l.createdAt ?? '',
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
};
});
const csv = rowsToCsv(headers, rows);
const today = new Date().toISOString().slice(0, 10);
downloadCsv(`leads-${tab}-${today}.csv`, csv);
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
}; };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
setSelectedIds([]);
}; };
// Build active filters for pills display // Build active filters for pills display
@@ -191,27 +229,6 @@ export const AllLeadsPage = () => {
setCurrentPage(1); setCurrentPage(1);
}; };
const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length;
const tabItems = [
{ id: 'new', label: 'New', badge: tab === 'new' ? total : undefined },
{ id: 'my-leads', label: 'My Leads', badge: tab === 'my-leads' ? myLeadsCount : undefined },
{ id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined },
];
// Bulk action modal state
const [isAssignOpen, setIsAssignOpen] = useState(false);
const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false);
const [isSpamOpen, setIsSpamOpen] = useState(false);
const selectedLeadsForAction = useMemo(
() => displayLeads.filter((l) => selectedIds.includes(l.id)),
[displayLeads, selectedIds],
);
const handleBulkAssign = () => setIsAssignOpen(true);
const handleBulkWhatsApp = () => setIsWhatsAppOpen(true);
const handleBulkSpam = () => setIsSpamOpen(true);
// Activity slideout state // Activity slideout state
const [activityLead, setActivityLead] = useState<Lead | null>(null); const [activityLead, setActivityLead] = useState<Lead | null>(null);
@@ -224,30 +241,12 @@ export const AllLeadsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="All Leads" subtitle={`${total} total`} /> <PageHeader
title="All Leads"
<div className="flex flex-1 flex-col overflow-hidden"> subtitle={`${total} total`}
{/* Tabs + Controls row */} infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary"> controls={
<div className="flex items-center gap-3"> <>
<Button
href="/"
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back to workspace"
/>
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
<TabList items={tabItems} type="button-gray" size="sm">
{(item) => (
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
)}
</TabList>
</Tabs>
</div>
<div className="flex items-center gap-2">
<div className="w-56"> <div className="w-56">
<Input <Input
placeholder="Search leads..." placeholder="Search leads..."
@@ -266,11 +265,15 @@ export const AllLeadsPage = () => {
size="sm" size="sm"
color="secondary" color="secondary"
iconLeading={Download01} iconLeading={Download01}
onClick={handleExportCsv}
> >
Export CSV Export CSV
</Button> </Button>
</div> </>
</div> }
/>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Active filters */} {/* Active filters */}
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
@@ -315,30 +318,6 @@ export const AllLeadsPage = () => {
</button> </button>
); );
})} })}
<button
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
campaignFilter === '__none__'
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
</button>
</div>
)}
{/* Bulk action bar */}
{selectedIds.length > 0 && (
<div className="shrink-0 px-6 pt-2">
<BulkActionBar
selectedCount={selectedIds.length}
onAssign={handleBulkAssign}
onWhatsApp={handleBulkWhatsApp}
onMarkSpam={handleBulkSpam}
onDeselect={() => setSelectedIds([])}
/>
</div> </div>
)} )}
@@ -346,8 +325,9 @@ export const AllLeadsPage = () => {
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
<LeadTable <LeadTable
leads={pagedLeads} leads={pagedLeads}
onSelectionChange={setSelectedIds} onSelectionChange={() => {}}
selectedIds={selectedIds} selectedIds={[]}
selectionMode="none"
sortField={sortField} sortField={sortField}
sortDirection={sortDirection} sortDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
@@ -368,52 +348,6 @@ export const AllLeadsPage = () => {
)} )}
</div> </div>
{/* Bulk action modals */}
{selectedLeadsForAction.length > 0 && (
<>
<AssignModal
isOpen={isAssignOpen}
onOpenChange={setIsAssignOpen}
selectedLeads={selectedLeadsForAction}
agents={agents}
onAssign={(agentId) => {
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
selectedIds.forEach((id) => {
updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' });
});
setIsAssignOpen(false);
setSelectedIds([]);
}}
/>
<WhatsAppSendModal
isOpen={isWhatsAppOpen}
onOpenChange={setIsWhatsAppOpen}
selectedLeads={selectedLeadsForAction}
templates={templates.filter((t) => t.approvalStatus === 'APPROVED')}
onSend={() => {
setIsWhatsAppOpen(false);
setSelectedIds([]);
}}
/>
</>
)}
{/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */}
{selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && (
<MarkSpamModal
isOpen={isSpamOpen}
onOpenChange={setIsSpamOpen}
lead={selectedLeadsForAction[0]}
onConfirm={() => {
selectedIds.forEach((id) => {
updateLead(id, { isSpam: true, leadStatus: 'LOST' });
});
setIsSpamOpen(false);
setSelectedIds([]);
}}
/>
)}
{/* Activity slideout */} {/* Activity slideout */}
{activityLead && ( {activityLead && (
<LeadActivitySlideout <LeadActivitySlideout

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