51 Commits

Author SHA1 Message Date
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
73 changed files with 7321 additions and 1638 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

View File

@@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed)
├── global.engage.healix360.net → sidecar-global:4100 ├── global.engage.healix360.net → sidecar-global:4100
├── telephony.engage.healix360.net → telephony:4200 ├── telephony.engage.healix360.net → telephony:4200
├── *.app.healix360.net → server:4000 (platform) ├── *.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) └── engage.healix360.net → 404 (no catchall)
Docker Compose stack (EC2 — 13.234.31.194): Docker Compose stack (EC2 — 13.234.31.194):
@@ -28,7 +31,9 @@ Docker Compose stack (EC2 — 13.234.31.194):
├── db — PostgreSQL 16 (workspace-per-schema) ├── db — PostgreSQL 16 (workspace-per-schema)
├── clickhouse — Analytics ├── clickhouse — Analytics
├── minio — S3-compatible object storage ├── minio — S3-compatible object storage
── redpanda — Event bus (Kafka-compatible) ── redpanda — Event bus (Kafka-compatible)
├── loki — Log aggregation (receives from Docker logging driver)
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
``` ```
--- ---
@@ -36,38 +41,35 @@ Docker Compose stack (EC2 — 13.234.31.194):
## EC2 Access ## EC2 Access
```bash ```bash
# SSH into EC2 # SSH into EC2 (key passphrase handled by sshpass)
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194 SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
``` ```
| Detail | Value | | Detail | Value |
|---|---| |---|---|
| Host | `13.234.31.194` | | Host | `13.234.31.194` |
| User | `ubuntu` | | User | `ubuntu` |
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) | | SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
| Passphrase | `SasiSuman@2007` |
| Docker compose dir | `/opt/fortytwo` | | Docker compose dir | `/opt/fortytwo` |
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` | | Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
| Caddyfile | `/opt/fortytwo/Caddyfile` | | Caddyfile | `/opt/fortytwo/Caddyfile` |
### SSH Key Setup ### SSH Helper
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`). The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
Create a decrypted copy for non-interactive use: No need to decrypt or copy the key — use the original file directly.
```bash ```bash
# One-time setup # SSH shorthand
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
chmod 600 /tmp/ramaiah-ec2-key
# Verify # Verify
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname eval $EC2_SSH hostname
``` ```
### Handy alias > **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
```bash
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
```
--- ---
@@ -80,6 +82,9 @@ alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234
| Ramaiah Platform | `https://ramaiah.app.healix360.net` | | Ramaiah Platform | `https://ramaiah.app.healix360.net` |
| Global Platform | `https://global.app.healix360.net` | | Global Platform | `https://global.app.healix360.net` |
| Telephony Dispatcher | `https://telephony.engage.healix360.net` | | Telephony Dispatcher | `https://telephony.engage.healix360.net` |
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
| CI/CD (Woodpecker) | `https://operations.healix360.net` |
| Git (Gitea) | `https://git.healix360.net` |
--- ---
@@ -155,29 +160,34 @@ REDIS_URL=redis://localhost:6379
### Frontend ### Frontend
```bash ```bash
# Helper — reuse in all commands below
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
cd helix-engage && npm run build cd helix-engage && npm run build
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \ rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/ dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
"cd /opt/fortytwo && sudo docker compose restart caddy"
``` ```
### Sidecar (quick — code only, no new dependencies) ### Sidecar
```bash ```bash
cd helix-engage-server cd helix-engage-server
# 1. Login to ECR
aws ecr get-login-password --region ap-south-1 | \ aws ecr get-login-password --region ap-south-1 | \
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# 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 .
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ # 3. Pull and restart on EC2
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global" eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
``` ```
### How to decide ### How to decide

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

File diff suppressed because it is too large Load Diff

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.

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

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

@@ -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,15 @@ 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 { 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';
@@ -42,12 +46,64 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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 } = 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 { 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 wasAnsweredRef = useRef(callState === 'active');
@@ -99,6 +155,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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,
}; };
@@ -110,24 +167,9 @@ 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: {
name: `Follow-up — ${fullName || phoneDisplay}`,
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');
}
}
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback // Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
localStorage.removeItem('helix_active_ucid'); localStorage.removeItem('helix_active_ucid');
@@ -136,15 +178,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
handleReset(); handleReset();
}; };
const handleAppointmentSaved = () => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
setSuggestedDisposition('APPOINTMENT_BOOKED'); 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'); notify.success('Appointment Booked', 'Payment link will be sent to the patient');
}
}; };
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
setCallUcid(null); setCallUcid(null);
@@ -208,7 +259,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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
@@ -235,8 +286,16 @@ 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>
<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> <Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div> </div>
</div>
{/* Call controls */} {/* Call controls */}
<div className="mt-3 flex items-center gap-1.5 flex-wrap"> <div className="mt-3 flex items-center gap-1.5 flex-wrap">
@@ -279,12 +338,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={!wasAnsweredRef.current}
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={!wasAnsweredRef.current}
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={!wasAnsweredRef.current}
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"
@@ -304,19 +368,92 @@ 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
@@ -327,22 +464,79 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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} // wasAnsweredRef only flips true once callState reaches
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
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);
@@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
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', { agentId, 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));
@@ -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',
)} )}
> >
{changing ? (
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
) : (
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} /> <FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span> )}
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />} <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

@@ -19,15 +19,31 @@ interface AiChatPanelProps {
onChatStart?: () => void; onChatStart?: () => void;
} }
// Supervisor has different quick-action prompts than the CC agent — they
// ask about team metrics, not patient / doctor info. Hardcoded here rather
// than in theme tokens because the prompts map 1:1 to the supervisor tool
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
// get_campaign_stats) — changing the tools means changing these prompts.
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.';
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { export const AiChatPanel = ({ callerContext, 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 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: {
@@ -49,6 +65,40 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
} }
}, [messages, onChatStart]); }, [messages, onChatStart]);
// Auto-fire a patient-summary request when a caller with a leadId appears
// on the panel. Resets whenever the caller changes (new incoming call) or
// the call ends (leadId clears), so each call starts fresh. The sidecar's
// AI agent inspects the leadId and replies with appointment/disposition/
// notes history when the caller is a returning patient.
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
// Call ended or no caller — wipe the panel so the next caller's
// context doesn't bleed over and the agent isn't staring at a stale
// summary in the worklist view between calls.
if (!leadId) {
if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null;
setMessages([]);
chatStartedRef.current = false;
}
return;
}
if (autoFiredForLeadRef.current === leadId) return;
// New caller — clear any prior chat state and fire the summary prompt.
autoFiredForLeadRef.current = leadId;
setMessages([]);
chatStartedRef.current = false;
const name = callerContext?.leadName ?? 'this caller';
append({
role: 'user',
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
});
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
const handleQuickAction = (prompt: string) => { const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt }); append({ role: 'user', content: prompt });
}; };
@@ -60,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
<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">
Ask me about doctors, clinics, packages, or patient info. {introText}
</p> </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) => (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
@@ -6,7 +6,7 @@ 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';
@@ -18,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;
}; };
@@ -29,8 +30,16 @@ 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 };
@@ -56,6 +65,7 @@ export const AppointmentForm = ({
patientId, patientId,
onSaved, onSaved,
existingAppointment, existingAppointment,
readOnly = false,
}: AppointmentFormProps) => { }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment; const isEditMode = !!existingAppointment;
@@ -79,7 +89,11 @@ export const AppointmentForm = ({
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 [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);
@@ -108,12 +122,35 @@ export const AppointmentForm = ({
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>( apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, `/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
).then(slots => { ).then(slots => {
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label }))); // Filter by selected clinic — doctor may visit multiple branches
// Auto-select clinic from the slot's clinic const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
if (slots.length > 0 && !clinic) { 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); 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([])); }).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]); }, [doctor, date]);
// Availability state // Availability state
@@ -210,13 +247,25 @@ export const AppointmentForm = ({
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) {
@@ -238,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 }
@@ -251,18 +302,65 @@ 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
await apiClient.graphql( const appointmentData: Record<string, any> = {
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
}`,
{
data: {
scheduledAt, scheduledAt,
durationMin: 30, durationMin: 30,
appointmentType: 'CONSULTATION', appointmentType: 'CONSULTATION',
@@ -271,9 +369,17 @@ export const AppointmentForm = ({
department: selectedDoctor?.department ?? '', department: selectedDoctor?.department ?? '',
doctorId: doctor, doctorId: doctor,
reasonForVisit: chiefComplaint || null, reasonForVisit: chiefComplaint || null,
...(patientId ? { patientId } : {}), ...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
}, ...(clinic ? { clinicId: clinic } : {}),
}, ...(agentNotes ? { agentNotes } : {}),
...(source ? { source } : {}),
};
console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData));
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
}`,
{ data: appointmentData },
); );
// Determine whether the agent actually renamed the patient. // Determine whether the agent actually renamed the patient.
@@ -283,13 +389,19 @@ export const AppointmentForm = ({
const trimmedName = patientName.trim(); const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
// DO NOT update the shared Patient entity when name changes // Update patient name when the agent explicitly renamed.
// during appointment creation. The Patient record is shared // `nameChanged` already requires isNameEditable=true (the
// across all appointments — modifying it here would // agent went through EditPatientConfirmModal), so the
// retroactively change the name on all past appointments. // rename intent is unambiguous. Bug #527's silent-overwrite
// The patient name for THIS appointment is stored on the // case can no longer happen because the confirm modal
// Appointment entity itself (via doctorName/department). // gates the input.
// Bug #527: removed updatePatient() call. 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));
}
// Update lead status/lastContacted on every appointment book // Update lead status/lastContacted on every appointment book
// (those are genuinely about this appointment), but only // (those are genuinely about this appointment), but only
@@ -316,21 +428,14 @@ export const AppointmentForm = ({
// If the agent actually renamed the patient, kick off the // If the agent actually renamed the patient, kick off the
// side-effect chain: regenerate the AI summary against the // side-effect chain: regenerate the AI summary against the
// corrected identity AND invalidate the Redis caller // corrected identity. Fire-and-forget; the save toast
// resolution cache so the next incoming call from this // fires immediately regardless.
// phone picks up fresh data. Both are fire-and-forget —
// the save toast fires immediately either way.
if (nameChanged && leadId) { if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {}); apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} else if (callerNumber) {
// No rename but still invalidate the cache so status +
// lastContacted updates propagate cleanly to the next
// lookup.
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
} }
} }
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.');
@@ -353,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 {
@@ -389,7 +494,7 @@ export const AppointmentForm = ({
placeholder="Full name" placeholder="Full name"
value={patientName} value={patientName}
onChange={setPatientName} onChange={setPatientName}
isDisabled={!isNameEditable} isDisabled={readOnly || !isNameEditable}
/> />
</div> </div>
{!isNameEditable && initialLeadName.length > 0 && ( {!isNameEditable && initialLeadName.length > 0 && (
@@ -462,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>
@@ -473,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>
@@ -485,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>
@@ -503,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',
@@ -531,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}
/> />
@@ -567,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>
@@ -577,9 +688,11 @@ export const AppointmentForm = ({
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}> <Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>
{!readOnly && (
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}> <Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'} {isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button> </Button>
)}
</div> </div>
</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,4 +1,5 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faSparkles, faPhone, faChevronDown, faChevronUp, faSparkles, faPhone, faChevronDown, faChevronUp,
@@ -9,11 +10,29 @@ import { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
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, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
// The context panel can render for any worklist item — not just leads.
// Missed calls and follow-ups provide a subset of the fields (phone +
// patientId + name) without a full Lead entity. ContextPanelSubject
// captures the minimum the panel needs to render P360.
export type ContextPanelSubject = {
id: string;
contactName?: { firstName: string; lastName: string } | null;
contactPhone?: Array<{ number: string; callingCode: string }> | null;
patientId?: string | null;
// Lead-specific fields — present when the subject IS a lead
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: LeadActivity[];
calls: Call[]; calls: Call[];
followUps: FollowUp[]; followUps: FollowUp[];
@@ -58,6 +77,7 @@ const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
); );
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => { export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const navigate = useNavigate();
const [contextExpanded, setContextExpanded] = useState(true); const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true); const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true); const [actionsExpanded, setActionsExpanded] = useState(true);
@@ -85,14 +105,14 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
); );
const leadFollowUps = useMemo(() => const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3), .slice(0, 3),
[followUps, lead], [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')
@@ -109,7 +129,7 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
// Linked patient // Linked patient
const linkedPatient = useMemo(() => const linkedPatient = useMemo(() =>
patients.find(p => p.id === (lead as any)?.patientId), patients.find(p => p.id === lead?.patientId),
[patients, lead], [patients, lead],
); );
@@ -120,6 +140,44 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length); const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
// Edit mode takes over the whole right panel — otherwise the
// AppointmentForm competes with the AI panel + context blocks for
// vertical space and gets crushed into a tiny strip at the bottom.
if (editingAppointment) {
return (
<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>
<button
onClick={() => setEditingAppointment(null)}
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
>
Back to context
</button>
</div>
<AppointmentForm
isOpen={true}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
clinicId: editingAppointment.clinicId ?? undefined,
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Lead header — always visible */} {/* Lead header — always visible */}
@@ -163,6 +221,16 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
</div> </div>
)} )}
{/* Campaign info */}
{(lead.utmCampaign || lead.campaignId) && (
<div className="flex items-center gap-1.5 px-1 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
<Badge size="sm" color="brand" type="pill-color">
{lead.utmCampaign ?? lead.campaignId}
</Badge>
</div>
)}
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */} {/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && ( {(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
<div> <div>
@@ -223,6 +291,12 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
{linkedPatient.patientType && ( {linkedPatient.patientType && (
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge> <Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
)} )}
<button
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
>
View 360
</button>
</div> </div>
)} )}
</div> </div>
@@ -298,28 +372,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} /> <AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
</div> </div>
{/* Appointment edit form */}
{editingAppointment && (
<AppointmentForm
isOpen={!!editingAppointment}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
)}
</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

@@ -22,7 +22,11 @@ type EnquiryFormProps = {
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;
}; };
@@ -79,17 +83,20 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
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;
} }
// Determine whether the agent actually renamed the patient.
// Only a non-empty, changed-from-initial name counts — empty
// strings or an unchanged name never trigger the rename
// chain, even if the field was unlocked.
const trimmedName = patientName.trim(); const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
const nameParts = { const nameParts = {
@@ -97,10 +104,49 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
lastName: trimmedName.split(' ').slice(1).join(' ') || '', lastName: trimmedName.split(' ').slice(1).join(' ') || '',
}; };
if (leadId) { if (isNew) {
// Update existing lead with enquiry details. Only touches // Net-new caller — create Patient + Lead with the typed
// contactName if the agent explicitly renamed — otherwise // name. Name is required (validated above).
// we leave the existing caller identity alone. 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 } }`,
{ {
@@ -114,34 +160,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
}, },
}, },
); );
} else {
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
} }
// Update linked patient's name ONLY if the agent explicitly // Update linked patient's name when the agent renamed (edit
// renamed. Fixes the long-standing bug where typing a name // confirm path) on an existing record. Skipped for isNew
// into this form silently overwrote the existing patient // because the patient was just created with the right name.
// record. if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
if (nameChanged && patientId) {
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: nameParts, fullName: nameParts,
}, },
@@ -149,14 +177,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
).catch((err: unknown) => console.warn('Failed to update patient name:', err)); ).catch((err: unknown) => console.warn('Failed to update patient name:', err));
} }
// Post-save side-effects. If the agent actually renamed the // Post-save side-effect. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation. // patient, kick off AI summary regen. Fire-and-forget.
// Otherwise just invalidate the cache so the status update
// propagates.
if (nameChanged && leadId) { if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {}); apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
} }
// Create follow-up if needed // Create follow-up if needed
@@ -166,6 +190,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
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 } }`,
{ {
@@ -176,7 +206,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
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 },
@@ -184,7 +214,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
} }
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 {
@@ -251,11 +283,22 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3">
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" /> <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>

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">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-44 shrink-0">
<Input
placeholder="Search..."
icon={SearchLg}
size="sm"
value={search}
onChange={handleSearch}
aria-label="Search worklist"
/>
</div>
</div>
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button <button
key={sub} key={item.id}
onClick={() => { setMissedSubTab(sub); setPage(1); }} onClick={() => handleTabChange(item.id)}
className={cx( className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear', 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
missedSubTab === sub tab === item.id
? 'bg-brand-50 text-brand-700 border border-brand-200' ? 'bg-brand-solid text-white'
: 'text-tertiary hover:text-secondary hover:bg-secondary', : 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)} )}
> >
{sub} {item.label}{item.badge ? ` (${item.badge})` : ''}
{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> </button>
))} ))}
</div> </div>
)}
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
now only shows pending callbacks. Attempted is redundant once
the worklist is the single source of truth. */}
{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

@@ -399,6 +399,9 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
onChange={(dv: DateValue | null) => onChange={(dv: DateValue | null) =>
updateHoliday(idx, { date: dv ? dv.toString() : '' }) 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>
<div className="flex-1"> <div className="flex-1">

View File

@@ -17,6 +17,8 @@ export type TelephonyFormValues = {
did: string; did: string;
sipId: string; sipId: string;
campaignName: string; campaignName: string;
adminUsername: string;
adminPassword: string;
}; };
sip: { sip: {
domain: string; domain: string;
@@ -37,6 +39,8 @@ export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
did: '', did: '',
sipId: '', sipId: '',
campaignName: '', campaignName: '',
adminUsername: '',
adminPassword: '',
}, },
sip: { sip: {
domain: 'blr-pub-rtc4.ozonetel.com', domain: 'blr-pub-rtc4.ozonetel.com',
@@ -108,6 +112,27 @@ export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
value={value.ozonetel.campaignName} value={value.ozonetel.campaignName}
onChange={(v) => patchOzonetel({ campaignName: v })} 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>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">

View File

@@ -15,6 +15,7 @@ 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 { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -119,7 +120,13 @@ export const AppShell = ({ children }: AppShellProps) => {
<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 */} {/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && ( {(hasAgentConfig || isAdmin) && (
<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">
{/* GlobalSearch hidden — navigation on result click
routes to Patient 360 with stale appointment state
from the call desk. Revisit when the Patient 360
route properly resets context on mount. (#4) */}
{/* <GlobalSearch /> */}
<div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />} {isAdmin && <NotificationBell />}
{hasAgentConfig && ( {hasAgentConfig && (
<> <>
@@ -141,6 +148,7 @@ export const AppShell = ({ children }: AppShellProps) => {
</> </>
)} )}
</div> </div>
</div>
)} )}
<ResumeSetupBanner /> <ResumeSetupBanner />
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>

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,98 @@
// 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';
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) => (
<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>
{controls && (
<div className="flex items-center gap-2">
{controls}
</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,10 +12,10 @@ import {
faHospitalUser, faHospitalUser,
faCalendarCheck, faCalendarCheck,
faPhone, faPhone,
faAddressBook,
faUsers, faUsers,
faArrowRightFromBracket, faArrowRightFromBracket,
faTowerBroadcast, faTowerBroadcast,
faChartLine,
faFileAudio, faFileAudio,
faPhoneMissed, faPhoneMissed,
} from "@fortawesome/pro-duotone-svg-icons"; } from "@fortawesome/pro-duotone-svg-icons";
@@ -30,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";
@@ -44,12 +45,12 @@ 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);
@@ -62,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 },
@@ -91,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 },
@@ -102,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 },
@@ -132,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;
@@ -149,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
@@ -280,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,14 @@
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons'; import { faEye } 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 +22,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 +53,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);
@@ -95,6 +94,7 @@ export const LeadTable = ({
}, [leads, expandedDupId]); }, [leads, expandedDupId]);
const allColumns = [ const allColumns = [
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 }, { id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 }, { id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 }, { id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
@@ -107,18 +107,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) || c.id === 'view')
: 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 +142,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';
@@ -156,6 +156,7 @@ export const LeadTable = ({
id={row.id} id={row.id}
className="bg-warning-primary" className="bg-warning-primary"
> >
<Table.Cell />
<Table.Cell className="pl-10"> <Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span> <span className="text-xs text-tertiary">{phone}</span>
</Table.Cell> </Table.Cell>
@@ -189,17 +190,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 +207,26 @@ export const LeadTable = ({
key={row.id} key={row.id}
id={row.id} id={row.id}
className={cx( className={cx(
'group/row',
isSpamRow && !isSelected && 'bg-warning-primary', isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
> >
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
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"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{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 +310,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

@@ -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,45 +98,50 @@ 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();
} else { return;
// Standard sidecar endpoint — include agentId from agent config }
// 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 agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined; const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) }; const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
const { ok, data } = await postMaint(action.endpoint, payload, otp);
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { if (ok) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-maint-otp': otp,
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
console.log(`[MAINT] ${action.label}:`, data);
notify.success(action.label, data.message ?? 'Completed successfully'); notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false); onOpenChange(false);
setOtp(''); reset();
} else { } else {
setError(data.message ?? 'Failed'); setError(data.message ?? 'Failed');
} }
}
} catch { } catch {
setError('Request failed'); setError('Request failed');
} finally { } finally {
@@ -94,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">
@@ -120,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">
@@ -154,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">
@@ -162,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

@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state'; import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useUiFlags } from '@/hooks/use-ui-flags';
// Dismissible banner shown across the top of authenticated pages when // Dismissible banner shown across the top of authenticated pages when
// the hospital workspace has incomplete setup steps AND the admin has // the hospital workspace has incomplete setup steps AND the admin has
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
// - Not dismissed in the current browser session (resets on reload) // - Not dismissed in the current browser session (resets on reload)
export const ResumeSetupBanner = () => { export const ResumeSetupBanner = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { setupManaged } = useUiFlags();
const [state, setState] = useState<SetupState | null>(null); const [state, setState] = useState<SetupState | null>(null);
const [dismissed, setDismissed] = useState( const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1', () => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
); );
useEffect(() => { useEffect(() => {
if (!isAdmin || dismissed) return; if (!isAdmin || dismissed || setupManaged) return;
getSetupState() getSetupState()
.then(setState) .then(setState)
.catch(() => { .catch(() => {
// Non-fatal — if setup-state isn't reachable, just // Non-fatal — if setup-state isn't reachable, just
// skip the banner. The wizard still works. // skip the banner. The wizard still works.
}); });
}, [isAdmin, dismissed]); }, [isAdmin, dismissed, setupManaged]);
if (!isAdmin || !state || dismissed) return null; if (!isAdmin || !state || dismissed || setupManaged) return null;
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length; const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
if (incompleteCount === 0) return null; if (incompleteCount === 0) return null;

View File

@@ -10,27 +10,32 @@ type SectionCardProps = {
description: string; description: string;
icon: any; icon: any;
iconColor?: string; iconColor?: string;
href: 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; status?: SectionStatus;
}; };
// Settings hub card. Each card represents one setup-able section (Branding, // Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its // Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
// dedicated page. The status badge mirrors the wizard's setup-state so an // its dedicated page or triggers a parent-owned callback.
// admin can see at a glance which sections still need attention.
export const SectionCard = ({ export const SectionCard = ({
title, title,
description, description,
icon, icon,
iconColor = 'text-brand-primary', iconColor = 'text-brand-primary',
href, href,
onClick,
status = 'unknown', status = 'unknown',
}: SectionCardProps) => { }: SectionCardProps) => {
return ( const className = cx(
<Link 'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
to={href} );
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md" const body = (
> <>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary"> <div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
@@ -62,6 +67,19 @@ export const SectionCard = ({
)} )}
</div> </div>
)} )}
</>
);
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
return (
<Link to={href ?? '#'} className={className}>
{body}
</Link> </Link>
); );
}; };

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 {
const res = await fetch(`${API_URL}/api/supervisor/performance-alerts`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) });
.then(r => r.ok ? r.json() : null) if (!res.ok) return;
.then(data => setTeamPerf(data)) const json = await res.json();
.catch(() => {}); 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 {
const dismissAll = () => { await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true }))); method: 'POST',
}; headers: { Authorization: `Bearer ${token}` },
});
const activeAlerts = alerts.filter(a => !a.dismissed); } catch {
load();
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll }; }
}, [load]);
return { alerts, 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,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,9 +26,9 @@ 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, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
campaignName typeCustom status platform campaignName typeCustom status platform
startDate endDate startDate endDate
@@ -22,41 +36,61 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
amountSpent { amountMicros currencyCode } amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl { primaryLinkUrl } externalCampaignId platformUrl { primaryLinkUrl }
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
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
@@ -65,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 }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

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

@@ -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) => {
// Doctor name: prefer the relation's fullName (authoritative — pulled
// from the Doctor entity). Fall back to the denormalized doctorName
// field for legacy rows that predate the doctor relation being fetched.
const doctorFullName = n.doctor?.fullName
? `${n.doctor.fullName.firstName ?? ''} ${n.doctor.fullName.lastName ?? ''}`.trim()
: '';
return {
id: n.id, id: n.id,
createdAt: n.createdAt, createdAt: n.createdAt,
scheduledAt: n.scheduledAt, scheduledAt: n.scheduledAt,
durationMinutes: n.durationMin ?? 30, durationMinutes: n.durationMin ?? 30,
appointmentType: n.appointmentType, appointmentType: n.appointmentType,
appointmentStatus: n.status, appointmentStatus: n.status,
doctorName: n.doctorName, doctorName: doctorFullName || n.doctorName || null,
doctorId: n.doctor?.id ?? null, doctorId: n.doctor?.id ?? null,
department: n.department, department: n.department,
reasonForVisit: n.reasonForVisit, reasonForVisit: n.reasonForVisit,
patientId: n.patient?.id ?? null, patientId: n.patient?.id ?? null,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null, patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null, patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
clinicName: n.department ?? 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

@@ -5,10 +5,30 @@ 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 { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard"; import { SetupWizardPage } from "@/pages/setup-wizard";
import { useUiFlags } from "@/hooks/use-ui-flags";
const AdminSetupGuard = () => { const AdminSetupGuard = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />; 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";
@@ -23,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";
@@ -83,8 +105,11 @@ 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 />} />
{/* Admin-only routes */}
<Route element={<RequireAdmin />}>
<Route path="/team-performance" element={<TeamPerformancePage />} /> <Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} /> <Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} /> <Route path="/call-recordings" element={<CallRecordingsPage />} />
@@ -92,8 +117,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/team-dashboard" element={<TeamDashboardPage />} /> <Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} /> <Route path="/integrations" element={<IntegrationsPage />} />
<Route element={<RequireSelfServeSetup />}>
{/* Settings hub + section pages */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} /> <Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} /> <Route path="/settings/clinics" element={<ClinicsPage />} />
@@ -101,6 +125,8 @@ createRoot(document.getElementById("root")!).render(
<Route path="/settings/telephony" element={<TelephonySettingsPage />} /> <Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} /> <Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} /> <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 />} />

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

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 = [
@@ -159,15 +167,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 +231,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 +243,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 +267,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 && (
@@ -329,25 +334,13 @@ export const AllLeadsPage = () => {
</div> </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>
)}
{/* Table — fills remaining space, scrolls internally */} {/* Table — fills remaining space, scrolls internally */}
<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 +361,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

View File

@@ -0,0 +1,725 @@
// Appointments v2 — lean table + detail side panel + reschedule + reminder
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
} from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table';
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header
import { Button } from '@/components/base/buttons/button';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Select } from '@/components/base/select/select';
import { DatePicker } from '@/components/application/date-picker/date-picker';
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PageHeader } from '@/components/layout/page-header';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
type AppointmentRecord = {
id: string;
scheduledAt: string | null;
durationMin: number | null;
appointmentType: string | null;
status: string | null;
doctorName: string | null;
department: string | null;
reasonForVisit: string | null;
patient: {
id: string;
fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null;
} | null;
clinic: {
id?: string;
clinicName: string;
} | null;
doctor: {
id: string;
fullName?: { firstName: string; lastName: string } | null;
} | null;
};
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
SCHEDULED: 'brand',
CONFIRMED: 'brand',
COMPLETED: 'success',
CANCELLED: 'error',
NO_SHOW: 'warning',
RESCHEDULED: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Booked',
CONFIRMED: 'Confirmed',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
NO_SHOW: 'No Show',
RESCHEDULED: 'Rescheduled',
};
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { id clinicName }
doctor { id fullName { firstName lastName } }
} } } }`;
const formatDateTime = (iso: string): string =>
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
const getPatientName = (appt: AppointmentRecord): string => {
if (!appt.patient?.fullName) return 'Unknown';
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
};
const getPhone = (appt: AppointmentRecord): string =>
appt.patient?.phones?.primaryPhoneNumber ?? '';
const isUpcoming = (appt: AppointmentRecord): boolean => {
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
if (!appt.scheduledAt) return false;
return new Date(appt.scheduledAt).getTime() >= Date.now();
};
// Can edit/reschedule: anything that isn't completed or cancelled
const canEdit = (appt: AppointmentRecord): boolean => {
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
};
const buildReminderMessage = (appt: AppointmentRecord): string => {
const name = getPatientName(appt);
const doctor = appt.doctorName ?? 'your doctor';
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
const branch = appt.clinic?.clinicName ?? 'our clinic';
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
};
// ── Detail Panel ─────────────────────────────────────────────────
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
<div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">{label}</p>
<p className="text-sm text-primary mt-0.5">{value || '—'}</p>
</div>
</div>
);
const AppointmentDetailPanel = ({
appointment,
onClose,
onReschedule,
}: {
appointment: AppointmentRecord;
onClose: () => void;
onReschedule: () => void;
}) => {
const editable = canEdit(appointment);
const phone = getPhone(appointment);
const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Appointment Details</h3>
<div className="flex items-center gap-1">
{editable && (
<button
onClick={() => setReschedulePromptOpen(true)}
title="Reschedule appointment"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-4" />
</button>
)}
<button
onClick={onClose}
className="flex size-8 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>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
<div className="mb-4">
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
</Badge>
</div>
{/* Date & Time — 2 lines */}
<div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">Date & Time</p>
{appointment.scheduledAt ? (
<>
<p className="text-sm text-primary mt-0.5">{formatDateOnly(appointment.scheduledAt)}</p>
<p className="text-xs text-tertiary">{formatTimeOnly(appointment.scheduledAt)}</p>
</>
) : <p className="text-sm text-quaternary mt-0.5"></p>}
</div>
</div>
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
<div className="border-t border-secondary pt-3 mt-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary mb-1">Patient</p>
<p className="text-sm font-medium text-primary">{getPatientName(appointment)}</p>
{phone && (
<div className="mt-1">
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
</div>
)}
</div>
</div>
{/* Reschedule confirm modal — same pattern as call desk */}
<ModalOverlay
isOpen={reschedulePromptOpen}
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
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={() => setReschedulePromptOpen(false)}>
No, just view
</Button>
<Button size="sm" color="primary" onClick={() => { setReschedulePromptOpen(false); onReschedule(); }}>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
};
// ── Reschedule Panel ─────────────────────────────────────────────
// Dedicated form for rescheduling from the Appointments page.
// No patient creation, no lead updates, no modal — just update the
// existing appointment's doctor, date, time, and chief complaint.
type Doctor = { id: string; name: string; department: string };
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department
} } } }`;
const ReschedulePanel = ({
appointment,
onClose,
onSaved,
}: {
appointment: AppointmentRecord;
onClose: () => void;
onSaved: () => void;
}) => {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [department, setDepartment] = useState(appointment.department ?? '');
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
const [timeSlot, setTimeSlot] = useState(() => {
if (!appointment.scheduledAt) return '';
const dt = new Date(appointment.scheduledAt);
return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
});
const [slots, setSlots] = useState<Array<{ id: string; label: string }>>([]);
const [reason, setReason] = useState(appointment.reasonForVisit ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cancelConfirm, setCancelConfirm] = useState(false);
// Fetch doctors once
useEffect(() => {
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
.then(data => {
const docs = data.doctors.edges.map((e: any) => {
const n = e.node;
const name = n.fullName
? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim()
: n.name;
return { id: n.id, name, department: n.department ?? '' };
});
setDoctors(docs);
})
.catch(() => {});
}, []);
// Departments derived from doctors
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
// Fetch slots when doctor + date change
useEffect(() => {
if (!doctor || !date) { setSlots([]); return; }
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
.then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label }))))
.catch(() => setSlots([]));
}, [doctor, date]);
const handleUpdate = async () => {
if (!doctor || !date || !timeSlot) {
setError('Please select doctor, date, and time slot');
return;
}
setSaving(true);
setError(null);
try {
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
const selectedDoc = doctors.find(d => d.id === doctor);
await apiClient.graphql(
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
{
id: appointment.id,
data: {
scheduledAt,
doctorName: selectedDoc?.name ?? appointment.doctorName,
department: department || appointment.department,
reasonForVisit: reason || null,
status: 'RESCHEDULED',
doctorId: doctor,
},
},
);
onSaved();
} catch (err: any) {
setError(err.message ?? 'Failed to update appointment');
} finally {
setSaving(false);
}
};
const handleCancel = async () => {
setSaving(true);
try {
await apiClient.graphql(
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
{ id: appointment.id, data: { status: 'CANCELLED' } },
);
notify.success('Appointment Cancelled');
onSaved();
} catch {
setError('Failed to cancel appointment');
} finally {
setSaving(false);
}
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Reschedule Appointment</h3>
<button
onClick={onClose}
className="flex size-8 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 overflow-y-auto px-5 py-4 space-y-4">
{/* Department */}
<div>
<span className="text-xs font-medium text-secondary">Department</span>
<Select
size="sm"
placeholder="Select department"
selectedKey={department}
onSelectionChange={(key) => { setDepartment(String(key)); setDoctor(''); }}
items={departments.map(d => ({ id: d, label: d.replace(/_/g, ' ') }))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
{/* Doctor */}
<div>
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
<Select
size="sm"
placeholder="Select doctor"
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(String(key))}
items={filteredDoctors.map(d => ({ id: d.id, label: d.name }))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
{/* Date */}
<div>
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker
value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day"
minValue={today(getLocalTimeZone())}
isDisabled={!doctor}
popoverPlacement="top start"
/>
</div>
{/* Time slots */}
{doctor && date && slots.length > 0 && (
<div>
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
<div className="mt-1 grid grid-cols-3 gap-1.5">
{slots.map(s => (
<button
key={s.id}
onClick={() => setTimeSlot(s.id)}
className={cx(
'rounded-lg border px-2 py-1.5 text-xs font-medium transition duration-100 ease-linear',
timeSlot === s.id
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary text-secondary hover:border-brand hover:text-brand-secondary',
)}
>
{s.label}
</button>
))}
</div>
</div>
)}
{doctor && date && slots.length === 0 && (
<p className="text-xs text-tertiary">No available slots for this date</p>
)}
{/* Chief Complaint */}
<div>
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="mt-1 w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary resize-y"
placeholder="Reason for visit..."
/>
</div>
{error && <p className="text-sm text-error-primary">{error}</p>}
</div>
{/* Footer buttons */}
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
Cancel Appointment
</Button>
<Button size="sm" color="primary" onClick={handleUpdate} isLoading={saving} isDisabled={!doctor || !date || !timeSlot}>
Update Appointment
</Button>
</div>
{/* Cancel confirm modal */}
<ModalOverlay
isOpen={cancelConfirm}
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
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">Cancel this appointment?</h2>
<p className="text-sm text-tertiary">
This will mark the appointment as cancelled. The patient will need to book a new appointment.
</p>
<div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setCancelConfirm(false)}>
No, keep it
</Button>
<Button size="sm" color="primary-destructive" onClick={() => { setCancelConfirm(false); handleCancel(); }} isLoading={saving}>
Yes, cancel appointment
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
};
// ── Page ─────────────────────────────────────────────────────────
export const AppointmentsPageV2 = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20;
const fetchAppointments = () => {
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchAppointments(); }, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of appointments) {
const s = a.status ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [appointments]);
const filtered = useMemo(() => {
let rows = appointments;
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(a => {
const name = getPatientName(a).toLowerCase();
const phone = getPhone(a);
const doctor = (a.doctorName ?? '').toLowerCase();
return name.includes(q) || phone.includes(q) || doctor.includes(q);
});
}
return rows;
}, [appointments, tab, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
useEffect(() => { setPage(1); }, [tab, search]);
const tabItems = [
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
];
const handleEditClick = (appt: AppointmentRecord) => {
setSelectedAppt(appt);
setPanelOpen(true);
setRescheduleOpen(false);
};
const handleSendReminder = (appt: AppointmentRecord) => {
const phone = getPhone(appt);
if (!phone) return;
const msg = encodeURIComponent(buildReminderMessage(appt));
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
};
const handleRescheduleSaved = () => {
setRescheduleOpen(false);
setPanelOpen(false);
setSelectedAppt(null);
fetchAppointments();
notify.success('Appointment Rescheduled');
};
return (
<>
<PageHeader
title="Appointments"
badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
controls={
<div className="w-56">
<Input
placeholder="Search patient, doctor..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search appointments"
/>
</div>
}
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</div>
}
/>
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading appointments...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="" className="w-8" isRowHeader />
<Table.Head label="PATIENT" className="min-w-[180px]" />
<Table.Head label="DATE & TIME" className="w-28" />
<Table.Head label="DOCTOR" className="min-w-[160px]" />
<Table.Head label="STATUS" className="w-24" />
<Table.Head label="REMIND" className="w-20" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
const name = getPatientName(appt);
const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
const upcoming = isUpcoming(appt);
const isSelected = selectedAppt?.id === appt.id;
return (
<Table.Row
id={appt.id}
className={cx('group/row', isSelected && 'bg-brand-primary')}
>
{/* Eye icon — first column */}
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
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"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{/* Patient: name + phone on 2 lines */}
<Table.Cell>
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{name}</p>
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div>
</Table.Cell>
{/* Date & Time: date + time on 2 lines */}
<Table.Cell>
{appt.scheduledAt ? (
<div>
<p className="text-sm text-primary">{formatDateOnly(appt.scheduledAt)}</p>
<p className="text-xs text-tertiary">{formatTimeOnly(appt.scheduledAt)}</p>
</div>
) : <span className="text-sm text-quaternary"></span>}
</Table.Cell>
{/* Doctor: name + department on 2 lines */}
<Table.Cell>
<div className="min-w-0">
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
</div>
</Table.Cell>
{/* Status */}
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
{/* Reminder */}
<Table.Cell>
{upcoming ? (
<button
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
title="Send WhatsApp reminder"
>
<FontAwesomeIcon icon={faBell} className="size-3" />
Send
</button>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</div>
{/* Detail side panel */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
)}>
{panelOpen && selectedAppt && !rescheduleOpen && (
<AppointmentDetailPanel
appointment={selectedAppt}
onClose={() => { setPanelOpen(false); setSelectedAppt(null); }}
onReschedule={() => setRescheduleOpen(true)}
/>
)}
{panelOpen && selectedAppt && rescheduleOpen && (
<ReschedulePanel
appointment={selectedAppt}
onClose={() => setRescheduleOpen(false)}
onSaved={handleRescheduleSaved}
/>
)}
</div>
</div>
</>
);
};

View File

@@ -27,8 +27,11 @@ type AppointmentRecord = {
fullName: { firstName: string; lastName: string } | null; fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null; phones: { primaryPhoneNumber: string } | null;
} | null; } | null;
clinic: {
clinicName: string;
} | null;
doctor: { doctor: {
clinic: { clinicName: string } | null; id: string;
} | null; } | null;
}; };
@@ -58,6 +61,7 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
id scheduledAt durationMin appointmentType status id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { clinicName }
doctor { id } doctor { id }
} } } }`; } } } }`;
@@ -103,7 +107,7 @@ export const AppointmentsPage = () => {
const phone = a.patient?.phones?.primaryPhoneNumber ?? ''; const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
const doctor = (a.doctorName ?? '').toLowerCase(); const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase(); const dept = (a.department ?? '').toLowerCase();
const branch = (a.department ?? '').toLowerCase(); const branch = (a.clinic?.clinicName ?? '').toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q); return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
}); });
} }
@@ -177,7 +181,7 @@ export const AppointmentsPage = () => {
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown' ? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
: 'Unknown'; : 'Unknown';
const phone = appt.patient?.phones?.primaryPhoneNumber ?? ''; const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
const branch = appt.department ?? '—'; const branch = appt.clinic?.clinicName ?? '—';
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
@@ -213,7 +217,7 @@ export const AppointmentsPage = () => {
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span> <span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span> <span className="text-xs text-tertiary truncate block max-w-[180px]" title={branch}>{branch}</span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color"> <Badge size="sm" color={statusColor} type="pill-color">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -8,26 +8,33 @@ import { useData } from '@/providers/data-provider';
import { useWorklist } from '@/hooks/use-worklist'; import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { WorklistPanel } from '@/components/call-desk/worklist-panel'; import { WorklistPanel } from '@/components/call-desk/worklist-panel';
import type { WorklistLead } from '@/components/call-desk/worklist-panel'; import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel'; import { ContextPanel } from '@/components/call-desk/context-panel';
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Input } from '@/components/base/input/input';
import { faIcon } from '@/lib/icon-wrapper';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const SearchLg = faIcon(faMagnifyingGlass);
export const CallDeskPage = () => { export const CallDeskPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound } = useSip(); const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
const { missedCalls, followUps, marketingLeads, loading } = useWorklist(); const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null); const [selectedLead, setSelectedLead] = useState<ContextPanelSubject | null>(null);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [contextOpen, setContextOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null); const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false); const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false); const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState(''); const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false); const [dialling, setDialling] = useState(false);
const [search, setSearch] = useState('');
// DEV: simulate incoming call // DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom); const setSimCallState = useSetAtom(sipCallStateAtom);
@@ -91,7 +98,7 @@ export const CallDeskPage = () => {
.then((result) => { .then((result) => {
setResolvedCaller(result); setResolvedCaller(result);
if (result.isNew) { if (result.isNew) {
notify.info('New Caller', 'Lead and patient records created'); notify.info('New Caller', 'No existing records found for this number');
} }
}) })
.catch((err) => { .catch((err) => {
@@ -108,9 +115,17 @@ export const CallDeskPage = () => {
} }
}, [isInCall]); }, [isInCall]);
// Build activeLead from resolved caller or fallback to client-side match // Build activeLead from resolved caller or fallback to client-side match.
// The resolver is the authoritative source for patientId (it just joined
// lead↔patient by phone), so overlay it on top of any worklist row that
// pre-dates the linkage. Without this, the Book Appt pills can't find
// a returning caller's prior appointments because the frontend loses
// sight of which patient they are.
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
const callerLead = resolvedCaller const callerLead = resolvedCaller
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? { ? workLead
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
: {
id: resolvedCaller.leadId, id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName }, contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }], contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
@@ -126,16 +141,59 @@ export const CallDeskPage = () => {
: selectedLead; : selectedLead;
const activeLeadFull = activeLead as any; const activeLeadFull = activeLead as any;
// Handle selection from any worklist row type. Leads use the lead
// object directly; missed calls and follow-ups build a synthetic
// lead-like object from their phone/patientId so the P360 context
// panel can render for any row type.
const handleSelectItem = useCallback((selection: WorklistSelection) => {
setSelectedItemId(selection.rowId);
if (selection.lead) {
// Lead row — use the full lead object as before
setSelectedLead(selection.lead);
return;
}
// Non-lead row (missed call, follow-up, callback) — build a
// ContextPanelSubject from the row's available data. The panel
// uses contactPhone for call-history matching and patientId for
// appointment/follow-up lookups. No type cast needed — the
// ContextPanelSubject type accepts these optional fields.
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
const subject: ContextPanelSubject = {
id: selection.leadId ?? selection.rowId,
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
patientId: selection.patientId,
};
setSelectedLead(subject);
}, []);
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */} {/* Header — matches PageHeader visual pattern */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> <div className="flex shrink-0 items-center justify-between px-6 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">Call Desk</h1> <h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span> <span className="text-sm text-tertiary ml-1">{user.name}</span>
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isInCall && (
<div className="w-52">
<Input
placeholder="Search worklist..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search worklist"
/>
</div>
)}
{import.meta.env.DEV && (!isInCall ? ( {import.meta.env.DEV && (!isInCall ? (
<button <button
onClick={startSimCall} onClick={startSimCall}
@@ -204,11 +262,11 @@ export const CallDeskPage = () => {
</div> </div>
<button <button
onClick={handleDial} onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10} disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear" className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
> >
<FontAwesomeIcon icon={faPhone} className="size-3.5" /> <FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : 'Call'} {dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
</button> </button>
</div> </div>
)} )}
@@ -242,9 +300,10 @@ export const CallDeskPage = () => {
followUps={followUps} followUps={followUps}
leads={marketingLeads} leads={marketingLeads}
loading={loading} loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)} onSelectItem={handleSelectItem}
selectedLeadId={selectedLead?.id ?? null} selectedItemId={selectedItemId}
onDialMissedCall={(id) => setActiveMissedCallId(id)} onDialMissedCall={(id) => setActiveMissedCallId(id)}
search={search}
/> />
)} )}
</div> </div>

View File

@@ -11,35 +11,46 @@ import {
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
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 { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { PageHeader } from '@/components/layout/page-header';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatShortDate, formatPhone } from '@/lib/format'; import { formatShortDate, formatPhone } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring'; // cx removed — no longer used after SLA column removal
import { cx } from '@/utils/cx';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider';
import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import type { Call, CallDirection, CallDisposition } from '@/types/entities'; import type { Call, CallDirection, CallDisposition } from '@/types/entities';
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed'; type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
const filterItems = [ const allFilterItems = [
{ id: 'all' as const, label: 'All Calls' }, { id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' }, { id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' }, { id: 'outbound' as const, label: 'Outbound' },
{ id: 'missed' as const, label: 'Missed' }, { id: 'missed' as const, label: 'Missed' },
]; ];
const agentFilterItems = [
{ id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' },
];
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 formatDuration = (seconds: number | null): string => { const formatDuration = (seconds: number | null): string => {
@@ -50,13 +61,6 @@ const formatDuration = (seconds: number | null): string => {
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}; };
const formatPhoneDisplay = (call: Call): string => {
if (call.callerNumber && call.callerNumber.length > 0) {
return formatPhone(call.callerNumber[0]);
}
return '\u2014';
};
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => { const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
if (status === 'MISSED') { if (status === 'MISSED') {
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />; return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
@@ -67,12 +71,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />; return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
}; };
const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => {
if (call.sla == null) return null;
const percent = Math.round(call.sla);
return { percent, status: computeSlaStatus(percent) };
};
const RecordingPlayer: FC<{ url: string }> = ({ url }) => { const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -84,8 +82,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
audio.pause(); audio.pause();
setIsPlaying(false); setIsPlaying(false);
} else { } else {
audio.play().catch(() => setIsPlaying(false)); audio.play().then(() => setIsPlaying(true)).catch(() => {});
setIsPlaying(true);
} }
}; };
@@ -115,11 +112,11 @@ const PAGE_SIZE = 20;
export const CallHistoryPage = () => { export const CallHistoryPage = () => {
const { calls, leads } = useData(); const { calls, leads } = useData();
const { user, isAdmin } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterKey>('all'); const [filter, setFilter] = useState<FilterKey>('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Build a map of lead names by ID for enrichment
const leadNameMap = useMemo(() => { const leadNameMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const lead of leads) { for (const lead of leads) {
@@ -131,7 +128,10 @@ export const CallHistoryPage = () => {
return map; return map;
}, [leads]); }, [leads]);
// Sort by time (newest first) and apply filters // Agent sees only their own calls; supervisor sees all
const agentConfig = localStorage.getItem('helix_agent_config');
const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const filteredCalls = useMemo(() => { const filteredCalls = useMemo(() => {
let result = [...calls].sort((a, b) => { let result = [...calls].sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
@@ -139,55 +139,71 @@ export const CallHistoryPage = () => {
return dateB - dateA; return dateB - dateA;
}); });
// Direction / status filter // CC agent: filter to own calls only.
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND'); // Match on the authoritative agent relation (set by CDR enrichment)
// or the raw agentName for unenriched rows. Chain names like
// "RamaiahAdmin -> GlobalHealthX" are split — last segment is
// the final handler. Missed calls have no handler and are excluded
// from the agent's personal history (they belong on the Missed
// Calls queue).
if (!isAdmin && myAgentId) {
const myId = myAgentId.toLowerCase();
result = result.filter((c) => {
// Missed calls have no handler — exclude from agent history
if (c.callStatus === 'MISSED') return false;
// Authoritative: agent relation from CDR enrichment
if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true;
// Fallback: parse chain in agentName, match last segment
if (c.agentName) {
const segments = c.agentName.split('->').map(s => s.trim().toLowerCase());
const finalHandler = segments[segments.length - 1];
if (finalHandler === myId) return true;
}
return false;
});
}
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND'); else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED'); else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
// Search filter
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
result = result.filter((c) => { result = result.filter((c) => {
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? ''; const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
const phone = c.callerNumber?.[0]?.number ?? ''; const phone = c.callerNumber?.[0]?.number ?? '';
const agent = c.agentName ?? ''; const agent = c.agentName ?? '';
return ( return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
name.toLowerCase().includes(q) ||
phone.includes(q) ||
agent.toLowerCase().includes(q)
);
}); });
} }
return result; return result;
}, [calls, filter, search, leadNameMap]); }, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]);
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length; const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length; const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Reset page when filter/search changes
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden p-7"> <PageHeader
<TableCard.Root size="md" className="flex-1 min-h-0"> title={isAdmin ? 'Call History' : 'My Call History'}
<TableCard.Header badge={filteredCalls.length}
title="Call History" subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
badge={String(filteredCalls.length)} infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
description={`${completedCount} completed \u00B7 ${missedCount} missed`} controls={
contentTrailing={ <>
<div className="flex items-center gap-2">
<div className="w-44"> <div className="w-44">
<Select <Select
size="sm" size="sm"
placeholder="All Calls" placeholder="All Calls"
selectedKey={filter} selectedKey={filter}
onSelectionChange={(key) => setFilter(key as FilterKey)} onSelectionChange={(key) => setFilter(key as FilterKey)}
items={filterItems} items={isAdmin ? allFilterItems : agentFilterItems}
aria-label="Filter calls" aria-label="Filter calls"
> >
{(item) => ( {(item) => (
@@ -207,10 +223,11 @@ export const CallHistoryPage = () => {
aria-label="Search calls" aria-label="Search calls"
/> />
</div> </div>
</div> </>
} }
/> />
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{filteredCalls.length === 0 ? ( {filteredCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16"> <div className="flex flex-col items-center justify-center py-16">
<h3 className="text-sm font-semibold text-primary">No calls found</h3> <h3 className="text-sm font-semibold text-primary">No calls found</h3>
@@ -226,17 +243,15 @@ export const CallHistoryPage = () => {
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" /> <Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" /> <Table.Head label="OUTCOME" />
<Table.Head label="SLA" className="w-24" /> {/* Agent columns — only visible for supervisor */}
<Table.Head label="AGENT" /> {isAdmin && <Table.Head label="AGENT" />}
<Table.Head label="RECORDING" className="w-24" /> {isAdmin && <Table.Head label="RECORDING" className="w-24" />}
<Table.Head label="TIME" /> <Table.Head label="TIME" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header> </Table.Header>
<Table.Body items={pagedCalls}> <Table.Body items={pagedCalls}>
{(call) => { {(call) => {
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
const phoneDisplay = formatPhoneDisplay(call);
const phoneRaw = call.callerNumber?.[0]?.number ?? ''; const phoneRaw = call.callerNumber?.[0]?.number ?? '';
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown');
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null; const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return ( return (
@@ -250,9 +265,14 @@ export const CallHistoryPage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap"> {phoneRaw ? (
{phoneDisplay} <PhoneActionCell
</span> phoneNumber={phoneRaw}
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
/>
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary whitespace-nowrap"> <span className="text-sm text-secondary whitespace-nowrap">
@@ -268,29 +288,14 @@ export const CallHistoryPage = () => {
<span className="text-sm text-quaternary">{'\u2014'}</span> <span className="text-sm text-quaternary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> {isAdmin && (
{(() => {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
})()}
</Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary"> <span className="text-sm text-secondary">
{call.agentName ?? '\u2014'} {call.agent?.name ?? call.agentName ?? '\u2014'}
</span> </span>
</Table.Cell> </Table.Cell>
)}
{isAdmin && (
<Table.Cell> <Table.Cell>
{call.recordingUrl ? ( {call.recordingUrl ? (
<RecordingPlayer url={call.recordingUrl} /> <RecordingPlayer url={call.recordingUrl} />
@@ -298,38 +303,28 @@ export const CallHistoryPage = () => {
<span className="text-xs text-quaternary">{'\u2014'}</span> <span className="text-xs text-quaternary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>
)}
<Table.Cell> <Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap"> <span className="text-sm text-tertiary whitespace-nowrap">
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'} {call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell>
{phoneRaw ? (
<ClickToCallButton
phoneNumber={phoneRaw}
leadId={call.leadId ?? undefined}
label="Call"
size="sm"
/>
) : (
<span className="text-xs text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
<div className="shrink-0"> </div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationCardDefault <PaginationCardDefault
page={page} page={page}
total={totalPages} total={totalPages}
onPageChange={setPage} onPageChange={setPage}
/> />
</div> </div>
</TableCard.Root> )}
</div>
</div> </div>
); );
}; };

View File

@@ -76,13 +76,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
}; };
const columnDefs = [ const columnDefs = [
{ id: 'agent', label: 'Agent', defaultVisible: true }, { id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true },
{ id: 'caller', label: 'Caller', defaultVisible: true }, { id: 'caller', label: 'Caller', defaultVisible: true },
{ id: 'ai', label: 'AI', defaultVisible: true }, { id: 'ai', label: 'AI', defaultVisible: true },
{ id: 'type', label: 'Type', defaultVisible: true }, { id: 'type', label: 'Type', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true }, { id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true }, { id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'duration', label: 'Duration', defaultVisible: true }, { id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
{ id: 'disposition', label: 'Disposition', defaultVisible: true }, { id: 'disposition', label: 'Disposition', defaultVisible: true },
{ id: 'recording', label: 'Recording', defaultVisible: true }, { id: 'recording', label: 'Recording', defaultVisible: true },
]; ];
@@ -96,6 +96,85 @@ export const CallRecordingsPage = () => {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' }); const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs); const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Dynamic columns for React Aria — filter by visibility, pass as prop
const activeColumns = useMemo(
() => columnDefs.filter(c => visibleColumns.has(c.id)),
[visibleColumns],
);
// Cell renderer — lives inside the component so it can access setSlideoutCallId
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
switch (colId) {
case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
case 'caller':
return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary"></span>;
case 'ai':
return (
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
e.stopPropagation();
let longPressed = false;
const timer = setTimeout(() => {
longPressed = true;
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
}, 1000);
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
document.addEventListener('pointerup', up, { once: true });
}}
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
title="AI Analysis (long-press to regenerate)"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
);
case 'type':
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
case 'sla': {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx('size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
}
case 'dateTime':
return call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
case 'duration':
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
case 'disposition':
return call.disposition
? <Badge size="sm" color="gray" type="pill-color">{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}</Badge>
: <span className="text-xs text-quaternary"></span>;
case 'recording':
return call.recording?.primaryLinkUrl
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
: null;
default:
return null;
}
}, []);
const fetchRecordings = useCallback(() => { const fetchRecordings = useCallback(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => { .then(data => {
@@ -186,119 +265,27 @@ export const CallRecordingsPage = () => {
) : ( ) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}> <Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header> <Table.Header columns={activeColumns}>
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />} {(col) => (
{visibleColumns.has('caller') && <Table.Head label="Caller" />} <Table.Head
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />} key={col.id}
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />} id={col.id}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />} label={col.label}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />} isRowHeader={col.isRowHeader}
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />} allowsSorting={col.allowsSorting}
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />} />
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />} )}
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(call) => { {(call) => (
const phone = call.callerNumber?.primaryPhoneNumber ?? ''; <Table.Row id={call.id} columns={activeColumns}>
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; {(col) => (
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; <Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)}
return (
<Table.Row id={call.id}>
{visibleColumns.has('agent') && (
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('caller') && (
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('ai') && (
<Table.Cell>
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
e.stopPropagation();
let longPressed = false;
const timer = setTimeout(() => {
longPressed = true;
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
}, 1000);
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
document.addEventListener('pointerup', up, { once: true });
}}
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
title="AI Analysis (long-press to regenerate)"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
</Table.Cell>
)}
{visibleColumns.has('type') && (
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
</Table.Cell>
)}
{visibleColumns.has('sla') && (
<Table.Cell>
{(() => {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
})()}
</Table.Cell>
)}
{visibleColumns.has('dateTime') && (
<Table.Cell>
{call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('duration') && (
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
)}
{visibleColumns.has('disposition') && (
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('recording') && (
<Table.Cell>
{call.recording?.primaryLinkUrl && (
<RecordingPlayer url={call.recording.primaryLinkUrl} />
)}
</Table.Cell> </Table.Cell>
)} )}
</Table.Row> </Table.Row>
); )}
}}
</Table.Body> </Table.Body>
</Table> </Table>
</div> </div>

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignHero } from '@/components/campaigns/campaign-hero'; import { CampaignHero } from '@/components/campaigns/campaign-hero';
import { KpiStrip } from '@/components/campaigns/kpi-strip'; import { KpiStrip } from '@/components/campaigns/kpi-strip';
import { AdCard } from '@/components/campaigns/ad-card'; import { AdCard } from '@/components/campaigns/ad-card';
@@ -9,28 +8,52 @@ import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
import { SourceBreakdown } from '@/components/campaigns/source-breakdown'; import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
import { BudgetBar } from '@/components/campaigns/budget-bar'; import { BudgetBar } from '@/components/campaigns/budget-bar';
import { HealthIndicator } from '@/components/campaigns/health-indicator'; import { HealthIndicator } from '@/components/campaigns/health-indicator';
import { Button } from '@/components/base/buttons/button'; import { LeadTable } from '@/components/leads/lead-table';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useCampaigns } from '@/hooks/use-campaigns'; import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads'; import { useLeads } from '@/hooks/use-leads';
import { useData } from '@/providers/data-provider';
import { formatCurrency, formatDateOnly } from '@/lib/format'; import { formatCurrency, formatDateOnly } from '@/lib/format';
import type { Lead } from '@/types/entities';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'leads', label: 'Leads' },
];
export const CampaignDetailPage = () => { export const CampaignDetailPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const { campaigns, ads } = useCampaigns(); const { campaigns, ads } = useCampaigns();
const { leads } = useLeads(); const { leads } = useLeads();
const { leadActivities } = useData();
const campaign = campaigns.find((c) => c.id === id); const campaign = campaigns.find((c) => c.id === id);
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]); const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]); const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [activityLead, setActivityLead] = useState<Lead | null>(null);
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
};
const sortedLeads = useMemo(() => {
const copy = [...campaignLeads];
const dir = sortDirection === 'asc' ? 1 : -1;
copy.sort((a, b) => {
const av = (a as any)[sortField] ?? '';
const bv = (b as any)[sortField] ?? '';
if (av === bv) return 0;
return av > bv ? dir : -dir;
});
return copy;
}, [campaignLeads, sortField, sortDirection]);
if (!campaign) { if (!campaign) {
return ( return (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
@@ -46,43 +69,52 @@ export const CampaignDetailPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* Hero header */}
<CampaignHero campaign={campaign} /> <CampaignHero campaign={campaign} />
{/* KPI strip */}
<KpiStrip campaign={campaign} /> <KpiStrip campaign={campaign} />
{/* Tabs */} {/* Main body: leads table on the left, campaign details + funnel + source on the right */}
<div className="px-7 pt-5"> <div className="px-7 pt-5 pb-7">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}> <div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
<TabList <div className="space-y-6">
type="underline" <div>
size="sm" <div className="mb-3 flex items-center justify-between">
items={detailTabs}
>
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
<TabPanel id="overview">
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
{/* Left: Ads list */}
<div className="space-y-3">
<h3 className="text-md font-bold text-primary"> <h3 className="text-md font-bold text-primary">
Ads ({campaignAds.length}) Leads ({campaignLeads.length})
</h3> </h3>
{campaignAds.map((ad) => ( </div>
<AdCard key={ad.id} ad={ad} /> {campaignLeads.length === 0 ? (
))} <div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
{campaignAds.length === 0 && ( No leads from this campaign yet.
<p className="py-8 text-center text-sm text-tertiary"> </div>
No ads for this campaign. ) : (
</p> <LeadTable
leads={sortedLeads}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
/>
)}
</div>
{campaignAds.length > 0 && (
<div>
<h3 className="mb-3 text-md font-bold text-primary">
Ads ({campaignAds.length})
</h3>
<div className="space-y-3">
{campaignAds.map((ad) => (
<AdCard key={ad.id} ad={ad} />
))}
</div>
</div>
)} )}
</div> </div>
{/* Right: Details + Funnel + Source */}
<div className="space-y-4"> <div className="space-y-4">
{/* Campaign Details card */}
<div className="rounded-xl border border-secondary bg-primary p-4"> <div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4> <h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs"> <dl className="space-y-2 text-xs">
@@ -138,34 +170,21 @@ export const CampaignDetailPage = () => {
</div> </div>
</div> </div>
{/* Conversion Funnel */}
<ConversionFunnel campaign={campaign} leads={campaignLeads} /> <ConversionFunnel campaign={campaign} leads={campaignLeads} />
{/* Source Breakdown */}
<SourceBreakdown leads={campaignLeads} /> <SourceBreakdown leads={campaignLeads} />
</div> </div>
</div> </div>
</TabPanel> </div>
<TabPanel id="leads"> {activityLead && (
<div className="mt-5 pb-7"> <LeadActivitySlideout
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center"> isOpen={!!activityLead}
<p className="text-md font-bold text-primary"> onOpenChange={(open) => !open && setActivityLead(null)}
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign lead={activityLead}
</p> activities={leadActivities}
<p className="mt-1 text-sm text-tertiary"> />
View the full leads table filtered by this campaign on the All Leads page. )}
</p>
<div className="mt-4">
<Button color="primary" size="sm" href="/leads">
Go to All Leads
</Button>
</div>
</div>
</div>
</TabPanel>
</Tabs>
</div>
</div> </div>
); );
}; };

176
src/pages/contacts.tsx Normal file
View File

@@ -0,0 +1,176 @@
// Contacts page — organic inbound callers (source = PHONE, WALK_IN,
// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads
// live on the Leads page; contacts are people who reached out directly
// without a marketing touchpoint.
//
// Uses the same LeadTable + column toggle + pagination pattern as
// All Leads. No separate backend endpoint — filters client-side on
// the DataProvider's leads array.
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useData } from '@/providers/data-provider';
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
import { notify } from '@/lib/toast';
import type { Lead } from '@/types/entities';
// Sources that qualify as "contacts" — direct/organic, not campaign-sourced
const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']);
const PAGE_SIZE = 15;
export const ContactsPage = () => {
const { leads, leadActivities } = useData();
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [activityLead, setActivityLead] = useState<Lead | null>(null);
const columnDefs = [
{ id: 'phone', label: 'Phone', defaultVisible: true },
{ id: 'name', label: 'Name', defaultVisible: true },
{ id: 'email', label: 'Email', defaultVisible: false },
{ id: 'source', label: 'Source', defaultVisible: true },
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
{ id: 'status', label: 'Status', defaultVisible: true },
{ id: 'createdAt', label: 'Age', defaultVisible: true },
];
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Filter to contact sources only
const contacts = useMemo(() => {
let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? ''));
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter((l) => {
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
const phone = l.contactPhone?.[0]?.number ?? '';
return name.includes(q) || phone.includes(q);
});
}
return filtered;
}, [leads, searchQuery]);
// Sort
const sorted = useMemo(() => {
const copy = [...contacts];
const dir = sortDirection === 'asc' ? 1 : -1;
copy.sort((a, b) => {
const av = (a as any)[sortField] ?? '';
const bv = (b as any)[sortField] ?? '';
if (av === bv) return 0;
return av > bv ? dir : -dir;
});
return copy;
}, [contacts, sortField, sortDirection]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
setCurrentPage(1);
};
const handleExportCsv = () => {
if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; }
const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact'];
const rows = sorted.map((l) => ({
'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 ?? '',
'Created': l.createdAt ?? '',
'Last Contact': l.lastContactedAt ?? '',
}));
const csv = rowsToCsv(headers, rows);
downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv);
notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="Contacts"
badge={contacts.length}
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
controls={
<>
<div className="w-56">
<Input
placeholder="Search contacts..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={(value) => { setSearchQuery(value); setCurrentPage(1); }}
aria-label="Search contacts"
/>
</div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
Export CSV
</Button>
</>
}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 pt-3">
<LeadTable
leads={paged}
selectedIds={[]}
onSelectionChange={() => {}}
selectionMode="none"
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
visibleColumns={visibleColumns}
/>
</div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={(page) => { setCurrentPage(page); }}
/>
</div>
)}
</div>
{activityLead && (
<LeadActivitySlideout
isOpen={!!activityLead}
onOpenChange={(open) => !open && setActivityLead(null)}
lead={activityLead}
activities={leadActivities}
/>
)}
</div>
);
};

View File

@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons'; import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
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 { BargeControls } from '@/components/call-desk/barge-controls';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
type ActiveCall = { type ActiveCall = {
ucid: string; ucid: string;
@@ -17,6 +19,18 @@ type ActiveCall = {
status: 'active' | 'on-hold'; status: 'active' | 'on-hold';
}; };
type CallerContext = {
name: string;
phone: string;
source: string | null;
status: string | null;
interestedService: string | null;
aiSummary: string | null;
patientType: string | null;
leadId: string | null;
appointments: Array<{ id: string; scheduledAt: string; doctorName: string; department: string; status: string }>;
};
const formatDuration = (startTime: string): string => { const formatDuration = (startTime: string): string => {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000)); const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
@@ -25,10 +39,10 @@ const formatDuration = (startTime: string): string => {
}; };
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => ( const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6"> <div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-4">
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mb-1" />
<p className="text-3xl font-bold text-primary">{value}</p> <p className="text-xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary mt-1">{label}</p> <p className="text-[11px] text-tertiary">{label}</p>
</div> </div>
); );
@@ -36,13 +50,23 @@ export const LiveMonitorPage = () => {
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]); const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [selectedCall, setSelectedCall] = useState<ActiveCall | null>(null);
const [callerContext, setCallerContext] = useState<CallerContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const { leads } = useData(); const { leads } = useData();
// Poll active calls every 5 seconds // Poll active calls every 5 seconds
useEffect(() => { useEffect(() => {
const fetchCalls = () => { const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true }) apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(setActiveCalls) .then(calls => {
setActiveCalls(calls);
// If selected call ended, clear selection
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -50,9 +74,9 @@ export const LiveMonitorPage = () => {
fetchCalls(); fetchCalls();
const interval = setInterval(fetchCalls, 5000); const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [selectedCall?.ucid]);
// Tick every second to update duration counters // Tick every second for duration display
useEffect(() => { useEffect(() => {
const interval = setInterval(() => setTick(t => t + 1), 1000); const interval = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -82,14 +106,68 @@ export const LiveMonitorPage = () => {
return null; return null;
}; };
// Fetch caller context when a call is selected
const handleSelectCall = (call: ActiveCall) => {
setSelectedCall(call);
setContextLoading(true);
setCallerContext(null);
const phoneClean = call.callerNumber.replace(/\D/g, '');
// Search for lead by phone
apiClient.graphql<{ leads: { edges: Array<{ node: any }> } }>(
`{ leads(first: 5, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phoneClean.slice(-10)}" } } }) { edges { node {
id contactName { firstName lastName } source status interestedService aiSummary patientId
} } } }`,
).then(async (data) => {
const lead = data.leads.edges[0]?.node;
const name = lead
? `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim()
: resolveCallerName(call.callerNumber) ?? 'Unknown Caller';
let appointments: CallerContext['appointments'] = [];
if (lead?.patientId) {
try {
const apptData = await apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(first: 5, filter: { patientId: { eq: "${lead.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt doctorName department status
} } } }`,
);
appointments = apptData.appointments.edges.map(e => e.node);
} catch { /* best effort */ }
}
setCallerContext({
name,
phone: call.callerNumber,
source: lead?.source ?? null,
status: lead?.status ?? null,
interestedService: lead?.interestedService ?? null,
aiSummary: lead?.aiSummary ?? null,
patientType: lead?.patientId ? 'RETURNING' : 'NEW',
leadId: lead?.id ?? null,
appointments,
});
}).catch(() => {
setCallerContext({
name: resolveCallerName(call.callerNumber) ?? 'Unknown Caller',
phone: call.callerNumber,
source: null, status: null, interestedService: null,
aiSummary: null, patientType: null, leadId: null, appointments: [],
});
}).finally(() => setContextLoading(false));
};
return ( return (
<> <>
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" /> <TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 overflow-hidden">
{/* Left panel — KPIs + call list */}
<div className="flex flex-1 flex-col overflow-y-auto border-r border-secondary">
{/* KPI Cards */} {/* KPI Cards */}
<div className="px-6 pt-5"> <div className="px-5 pt-4">
<div className="flex gap-4"> <div className="flex gap-3">
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} /> <KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
<KpiCard value={onHold} label="On Hold" icon={faPause} /> <KpiCard value={onHold} label="On Hold" icon={faPause} />
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} /> <KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
@@ -97,7 +175,7 @@ export const LiveMonitorPage = () => {
</div> </div>
{/* Active Calls Table */} {/* Active Calls Table */}
<div className="px-6 pt-6"> <div className="px-5 pt-5 pb-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3> <h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
{loading ? ( {loading ? (
@@ -118,16 +196,23 @@ export const LiveMonitorPage = () => {
<Table.Head label="Type" className="w-16" /> <Table.Head label="Type" className="w-16" />
<Table.Head label="Duration" className="w-20" /> <Table.Head label="Duration" className="w-20" />
<Table.Head label="Status" className="w-24" /> <Table.Head label="Status" className="w-24" />
<Table.Head label="Actions" className="w-48" />
</Table.Header> </Table.Header>
<Table.Body items={activeCalls}> <Table.Body items={activeCalls}>
{(call) => { {(call) => {
const callerName = resolveCallerName(call.callerNumber); const callerName = resolveCallerName(call.callerNumber);
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out'; const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand'; const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
const isSelected = selectedCall?.ucid === call.ucid;
return ( return (
<Table.Row id={call.ucid}> <Table.Row
id={call.ucid}
className={cx(
'cursor-pointer transition duration-100 ease-linear',
isSelected ? 'bg-active' : 'hover:bg-primary_hover',
)}
onAction={() => handleSelectCall(call)}
>
<Table.Cell> <Table.Cell>
<span className="text-sm font-medium text-primary">{call.agentId}</span> <span className="text-sm font-medium text-primary">{call.agentId}</span>
</Table.Cell> </Table.Cell>
@@ -148,13 +233,6 @@ export const LiveMonitorPage = () => {
{call.status} {call.status}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1.5">
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
@@ -162,18 +240,123 @@ export const LiveMonitorPage = () => {
</Table> </Table>
)} )}
</div> </div>
</div>
{/* Monitoring hint */} {/* Right panel — context + barge controls */}
{activeCalls.length > 0 && ( <div className="flex w-[380px] shrink-0 flex-col overflow-y-auto bg-primary">
<div className="px-6 pt-6 pb-8"> {!selectedCall ? (
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" /> <FontAwesomeIcon icon={faHeadset} className="size-10 text-fg-quaternary" />
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p> <p className="text-sm font-medium text-secondary">Select a call to monitor</p>
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p> <p className="text-xs text-tertiary">Click on any active call to see context and connect</p>
</div>
) : contextLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading caller context...</p>
</div>
) : (
<div className="flex flex-col gap-4 p-4">
{/* Caller header */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-brand-secondary text-sm font-bold text-fg-white">
{(callerContext?.name ?? '?')[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-primary truncate">{callerContext?.name}</p>
<p className="text-xs text-tertiary">{callerContext?.phone}</p>
</div>
{callerContext?.patientType && (
<Badge size="sm" color={callerContext.patientType === 'RETURNING' ? 'brand' : 'gray'} type="pill-color">
{callerContext.patientType === 'RETURNING' ? 'Returning' : 'New'}
</Badge>
)}
</div>
{/* Source + status */}
{(callerContext?.source || callerContext?.status) && (
<div className="mt-2 flex flex-wrap gap-1">
{callerContext.source && (
<Badge size="sm" color="gray" type="pill-color">{callerContext.source.replace(/_/g, ' ')}</Badge>
)}
{callerContext.status && (
<Badge size="sm" color="brand" type="pill-color">{callerContext.status.replace(/_/g, ' ')}</Badge>
)}
</div>
)}
{callerContext?.interestedService && (
<p className="mt-2 text-xs text-tertiary">Interested in: {callerContext.interestedService}</p>
)}
</div>
{/* AI Summary */}
{callerContext?.aiSummary && (
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
<div className="flex items-center gap-1 mb-1">
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
</div>
<p className="text-xs leading-relaxed text-primary">{callerContext.aiSummary}</p>
</div>
)}
{/* Appointments */}
{callerContext?.appointments && callerContext.appointments.length > 0 && (
<div className="rounded-xl border border-secondary bg-primary p-3">
<div className="flex items-center gap-1 mb-2">
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Appointments</span>
</div>
<div className="space-y-1.5">
{callerContext.appointments.map(appt => (
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">{appt.doctorName ?? 'Appointment'}</span>
{appt.department && <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.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'} type="pill-color">
{(appt.status ?? 'Scheduled').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Call info */}
<div className="rounded-xl border border-secondary bg-primary p-3">
<div className="flex items-center gap-1 mb-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-quaternary" />
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Current Call</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-tertiary">Agent:</span> <span className="font-medium text-primary">{selectedCall.agentId}</span></div>
<div><span className="text-tertiary">Type:</span> <span className="font-medium text-primary">{selectedCall.callType === 'InBound' ? 'Inbound' : 'Outbound'}</span></div>
<div><span className="text-tertiary">Duration:</span> <span className="font-mono font-medium text-primary">{formatDuration(selectedCall.startTime)}</span></div>
<div><span className="text-tertiary">Status:</span> <span className="font-medium text-primary">{selectedCall.status}</span></div>
</div>
</div>
{/* Barge Controls */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<BargeControls
ucid={selectedCall.ucid}
agentId={selectedCall.agentId}
agentNumber={selectedCall.agentId}
agentName={selectedCall.agentId}
onDisconnected={() => {
// Keep selection visible but controls reset to idle/ended
}}
/>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div>
</> </>
); );
}; };

View File

@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useThemeTokens } from '@/providers/theme-token-provider'; import { useThemeTokens } from '@/providers/theme-token-provider';
import { getSetupState } from '@/lib/setup-state'; import { getSetupState } from '@/lib/setup-state';
import { getUiFlags } from '@/hooks/use-ui-flags';
export const LoginPage = () => { export const LoginPage = () => {
const { loginWithUser } = useAuth(); const { loginWithUser } = useAuth();
@@ -118,11 +119,13 @@ export const LoginPage = () => {
// First-run detection: if the workspace's setup is incomplete and // First-run detection: if the workspace's setup is incomplete and
// the wizard hasn't been dismissed, route the admin to /setup so // the wizard hasn't been dismissed, route the admin to /setup so
// they finish onboarding before reaching the dashboard. Failures // they finish onboarding before reaching the dashboard. Skip when
// are non-blocking — we always have a fallback to /. // the tenant's setup is product-team managed — there's nothing
// for the admin to do in the wizard. Failures are non-blocking —
// we always have a fallback to /.
try { try {
const state = await getSetupState(); const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
if (state.wizardRequired) { if (state.wizardRequired && !flags.setupManaged) {
navigate('/setup'); navigate('/setup');
return; return;
} }

View File

@@ -9,7 +9,7 @@ import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; 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 { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -22,10 +22,10 @@ type MissedCallRecord = {
callerNumber: { primaryPhoneNumber: string } | null; callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null; agentName: string | null;
startedAt: string | null; startedAt: string | null;
callsourcenumber: string | null; callSourceNumber: string | null;
callbackstatus: string | null; callbackStatus: string | null;
missedcallcount: number | null; missedCallCount: number | null;
callbackattemptedat: string | null; callbackAttemptedAt: string | null;
sla: number | null; sla: number | null;
}; };
@@ -35,7 +35,7 @@ const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED } callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
} } } }`; } } } }`;
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -57,16 +57,108 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
}; };
const columnDefs = [ const columnDefs = [
{ id: 'caller', label: 'Caller', defaultVisible: true }, { id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true }, { id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'branch', label: 'Branch', defaultVisible: true }, { id: 'branch', label: 'Branch', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true }, { id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
{ id: 'count', label: 'Count', defaultVisible: true }, { id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
{ id: 'status', label: 'Status', defaultVisible: true }, { id: 'status', label: 'Status', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true }, { id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'callback', label: 'Callback At', defaultVisible: false }, { id: 'callback', label: 'Callback At', defaultVisible: false },
]; ];
// Dynamic columns table — React Aria requires the column count to match
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
// table (#8127). Using the dynamic collections API (columns prop +
// render function) lets React Aria rebuild its collection cleanly when
// the visible set changes.
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
const renderCell = (call: MissedCallRecord, colId: string) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
switch (colId) {
case 'caller':
return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary">Unknown</span>;
case 'dateTime':
return call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
case 'branch':
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
case 'count':
return call.missedCallCount && call.missedCallCount > 1
? <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
: <span className="text-xs text-quaternary">1</span>;
case 'status':
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
case 'sla':
if (call.sla == null) return <span className="text-xs text-quaternary"></span>;
const slaStatus = computeSlaStatus(call.sla);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx('size-2 rounded-full',
slaStatus === 'low' && 'bg-success-solid',
slaStatus === 'medium' && 'bg-warning-solid',
slaStatus === 'high' && 'bg-error-solid',
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{call.sla}%</span>
</span>
);
case 'callback':
return call.callbackAttemptedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
default:
return null;
}
};
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void;
}) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}>
{(col) => (
<Table.Head
key={col.id}
id={col.id}
label={col.label}
isRowHeader={col.isRowHeader}
allowsSorting={col.allowsSorting}
/>
)}
</Table.Header>
<Table.Body items={calls}>
{(call) => (
<Table.Row id={call.id} columns={columns}>
{(col) => (
<Table.Cell key={col.id}>
{renderCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const MissedCallsPage = () => { export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]); const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -92,7 +184,7 @@ export const MissedCallsPage = () => {
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const c of calls) { for (const c of calls) {
const s = c.callbackstatus ?? 'PENDING_CALLBACK'; const s = c.callbackStatus ?? 'PENDING_CALLBACK';
counts[s] = (counts[s] ?? 0) + 1; counts[s] = (counts[s] ?? 0) + 1;
} }
return counts; return counts;
@@ -100,16 +192,16 @@ export const MissedCallsPage = () => {
const filtered = useMemo(() => { const filtered = useMemo(() => {
let rows = calls; let rows = calls;
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus); if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'); else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'); else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(c => rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.agentName ?? '').toLowerCase().includes(q) || (c.agentName ?? '').toLowerCase().includes(q) ||
(c.callsourcenumber ?? '').toLowerCase().includes(q), (c.callSourceNumber ?? '').toLowerCase().includes(q),
); );
} }
@@ -122,7 +214,7 @@ export const MissedCallsPage = () => {
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0; const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return (ta - tb) * dir; return (ta - tb) * dir;
} }
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir; case 'count': return ((a.missedCallCount ?? 1) - (b.missedCallCount ?? 1)) * dir;
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir; case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir; case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
default: return 0; default: return 0;
@@ -146,23 +238,27 @@ export const MissedCallsPage = () => {
]; ];
return ( return (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + toolbar */} <PageHeader
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5"> title="Missed Calls"
badge={calls.length}
infoText="Inbound calls that were not answered. Agents can call back from here."
controls={
<>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</>
}
tabs={
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> <Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm"> <TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} {(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList> </TabList>
</Tabs> </Tabs>
<div className="flex items-center gap-3 pb-1"> }
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> />
<div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</div>
</div>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
@@ -175,101 +271,12 @@ export const MissedCallsPage = () => {
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p> <p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <DynamicMissedCallTable
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}> calls={pagedRows}
<Table.Header> columns={columnDefs.filter(c => visibleColumns.has(c.id))}
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />} sortDescriptor={sortDescriptor}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />} onSortChange={setSortDescriptor}
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />} />
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
</Table.Header>
<Table.Body items={pagedRows}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
return (
<Table.Row id={call.id}>
{visibleColumns.has('caller') && (
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary">Unknown</span>}
</Table.Cell>
)}
{visibleColumns.has('dateTime') && (
<Table.Cell>
{call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('branch') && (
<Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('agent') && (
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('count') && (
<Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell>
)}
{visibleColumns.has('status') && (
<Table.Cell>
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
{STATUS_LABELS[status] ?? status}
</Badge>
</Table.Cell>
)}
{visibleColumns.has('sla') && (
<Table.Cell>
{call.sla != null ? (() => {
const status = computeSlaStatus(call.sla);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
status === 'low' && 'bg-success-solid',
status === 'medium' && 'bg-warning-solid',
status === 'high' && 'bg-error-solid',
status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{call.sla}%</span>
</span>
);
})() : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('callback') && (
<Table.Cell>
{call.callbackattemptedat ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
)} )}
</div> </div>
@@ -284,6 +291,5 @@ export const MissedCallsPage = () => {
</div> </div>
)} )}
</div> </div>
</>
); );
}; };

View File

@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
], ],
barWidth: '50%', barWidth: '50%',
itemStyle: { borderRadius: [4, 4, 0, 0] }, itemStyle: { borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
}], }],
}} }}
style={{ height: 240 }} style={{ height: 240 }}
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
type: 'pie', type: 'pie',
radius: ['45%', '70%'], radius: ['45%', '70%'],
center: ['35%', '50%'], center: ['35%', '50%'],
avoidLabelOverlap: false, avoidLabelOverlap: true,
label: { show: false }, label: { show: true, formatter: '{d}%', fontSize: 11, color: '#344054', fontWeight: 600 },
labelLine: { show: true, length: 6, length2: 6 },
data: Object.entries(data.dispositions).map(([name, value], i) => ({ data: Object.entries(data.dispositions).map(([name, value], i) => ({
name, name,
value, value,

View File

@@ -15,8 +15,10 @@ import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { formatShortDate, getInitials } from '@/lib/format'; import { formatShortDate, getInitials } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities'; import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
@@ -49,11 +51,15 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = { const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
APPOINTMENT_BOOKED: 'success', APPOINTMENT_BOOKED: 'success',
APPOINTMENT_RESCHEDULED: 'warning',
APPOINTMENT_CANCELLED: 'error',
FOLLOW_UP_SCHEDULED: 'brand', FOLLOW_UP_SCHEDULED: 'brand',
INFO_PROVIDED: 'blue', INFO_PROVIDED: 'blue',
WRONG_NUMBER: 'error', WRONG_NUMBER: 'error',
NO_ANSWER: 'warning', NO_ANSWER: 'warning',
NOT_INTERESTED: 'error',
CALLBACK_REQUESTED: 'gray', CALLBACK_REQUESTED: 'gray',
CALL_DROPPED: 'gray',
}; };
const TABS = [ const TABS = [
@@ -96,15 +102,16 @@ type PatientData = {
}; };
// Appointment row component // Appointment row component
const AppointmentRow = ({ appt }: { appt: any }) => { const AppointmentRow = ({ appt, onEdit }: { appt: any; onEdit?: (appt: any) => void }) => {
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--'; const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = { const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning', CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
}; };
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
return ( return (
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0"> <div className={cx('flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0', canEdit && onEdit && 'cursor-pointer hover:bg-primary_hover transition duration-100 ease-linear')} onClick={() => canEdit && onEdit?.(appt)}>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary"> <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
<CalendarCheck className="size-4 text-fg-white" /> <CalendarCheck className="size-4 text-fg-white" />
</div> </div>
@@ -266,6 +273,9 @@ export const Patient360Page = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('appointments'); const [activeTab, setActiveTab] = useState<string>('appointments');
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
const [noteSaving, setNoteSaving] = useState(false);
const [apptFormOpen, setApptFormOpen] = useState(false);
const [editingAppt, setEditingAppt] = useState<any>(null);
const [patient, setPatient] = useState<PatientData | null>(null); const [patient, setPatient] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]); const [activities, setActivities] = useState<LeadActivity[]>([]);
@@ -383,6 +393,11 @@ export const Patient360Page = () => {
{leadInfo.source.replace(/_/g, ' ')} {leadInfo.source.replace(/_/g, ' ')}
</Badge> </Badge>
)} )}
{leadInfo?.status && (
<Badge size="sm" type="pill-color" color={leadInfo.status === 'CONVERTED' ? 'success' : leadInfo.status === 'NEW' ? 'brand' : 'gray'}>
{leadInfo.status.replace(/_/g, ' ')}
</Badge>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -423,7 +438,7 @@ export const Patient360Page = () => {
{phoneRaw && ( {phoneRaw && (
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" /> <ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" />
)} )}
<Button size="sm" color="secondary" iconLeading={Calendar}> <Button size="sm" color="secondary" iconLeading={Calendar} onClick={() => { setEditingAppt(null); setApptFormOpen(true); }}>
Book Appointment Book Appointment
</Button> </Button>
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}> <Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
@@ -472,7 +487,7 @@ export const Patient360Page = () => {
) : ( ) : (
<div className="rounded-xl border border-secondary bg-primary"> <div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => ( {appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} /> <AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
))} ))}
</div> </div>
)} )}
@@ -538,7 +553,25 @@ export const Patient360Page = () => {
size="sm" size="sm"
color="primary" color="primary"
iconLeading={Plus} iconLeading={Plus}
isDisabled={noteText.trim() === ''} isDisabled={noteText.trim() === '' || noteSaving}
isLoading={noteSaving}
onClick={async () => {
if (!noteText.trim() || !leadInfo?.id) return;
setNoteSaving(true);
try {
await apiClient.graphql(
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
);
setActivities(prev => [{ id: crypto.randomUUID(), createdAt: new Date().toISOString(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), channel: null, durationSeconds: null, outcome: null, leadId: leadInfo.id }, ...prev]);
setNoteText('');
notify.success('Note Added');
} catch {
notify.error('Failed', 'Could not save note');
} finally {
setNoteSaving(false);
}
}}
> >
Add Note Add Note
</Button> </Button>
@@ -563,6 +596,33 @@ export const Patient360Page = () => {
</Tabs> </Tabs>
</div> </div>
</div> </div>
<AppointmentForm
isOpen={apptFormOpen}
onOpenChange={setApptFormOpen}
callerNumber={phoneRaw || null}
leadName={fullName !== 'Unknown Patient' ? fullName : null}
leadId={leadInfo?.id ?? null}
patientId={id ?? null}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt,
doctorName: editingAppt.doctorName ?? '',
department: editingAppt.department ?? '',
reasonForVisit: editingAppt.reasonForVisit,
status: editingAppt.status,
} : null}
onSaved={() => {
setApptFormOpen(false);
setEditingAppt(null);
// Refresh patient data
if (id) {
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
PATIENT_QUERY, { id }, { silent: true },
).then(data => setPatient(data.patients.edges[0]?.node ?? null)).catch(() => {});
}
}}
/>
</> </>
); );
}; };

View File

@@ -1,18 +1,17 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router'; // useNavigate removed — row click opens profile panel
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
// Button removed — actions are icon-only now
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PageHeader } from '@/components/layout/page-header';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { getInitials } from '@/lib/format'; import { getInitials } from '@/lib/format';
@@ -56,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? ''; return patient.emails?.primaryEmail ?? '';
}; };
export const PatientsPage = () => { export const PatientsPage = () => {
const { patients, loading } = useData(); const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null); const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
@@ -86,17 +85,12 @@ export const PatientsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} /> <PageHeader
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<TableCard.Root size="sm">
<TableCard.Header
title="All Patients" title="All Patients"
badge={filteredPatients.length} badge={filteredPatients.length}
description="Manage and view patient records" infoText="Manage and view patient records"
contentTrailing={ controls={
<div className="flex items-center gap-2"> <>
<button <button
onClick={() => setPanelOpen(!panelOpen)} onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear" className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
@@ -115,10 +109,12 @@ export const PatientsPage = () => {
aria-label="Search patients" aria-label="Search patients"
/> />
</div> </div>
</div> </>
} }
/> />
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p> <p className="text-sm text-tertiary">Loading patients...</p>
@@ -135,13 +131,12 @@ export const PatientsPage = () => {
<Table> <Table>
<Table.Header> <Table.Header>
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" /> <Table.Head label="PHONE" />
<Table.Head label="TYPE" /> <Table.Head label="EMAIL" />
<Table.Head label="GENDER" /> <Table.Head label="GENDER" />
<Table.Head label="AGE" /> <Table.Head label="AGE" />
<Table.Head label="ACTIONS" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
const displayName = getPatientDisplayName(patient); const displayName = getPatientDisplayName(patient);
const age = computeAge(patient.dateOfBirth); const age = computeAge(patient.dateOfBirth);
@@ -156,7 +151,7 @@ export const PatientsPage = () => {
<Table.Row <Table.Row
id={patient.id} id={patient.id}
className={cx( className={cx(
'cursor-pointer', 'cursor-pointer group/row',
selectedPatient?.id === patient.id && 'bg-brand-primary' selectedPatient?.id === patient.id && 'bg-brand-primary'
)} )}
onAction={() => { onAction={() => {
@@ -184,28 +179,21 @@ export const PatientsPage = () => {
</div> </div>
</Table.Cell> </Table.Cell>
{/* Contact */} {/* Phone — clickable to dial */}
<Table.Cell> <Table.Cell>
<div className="flex flex-col">
{phone ? ( {phone ? (
<span className="text-sm text-secondary">{phone}</span> <PhoneActionCell phoneNumber={phone} displayNumber={phone} />
) : ( ) : (
<span className="text-sm text-placeholder">No phone</span> <span className="text-sm text-placeholder">No phone</span>
)} )}
{email ? (
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
) : null}
</div>
</Table.Cell> </Table.Cell>
{/* Type */} {/* Email */}
<Table.Cell> <Table.Cell>
{patient.patientType ? ( {email ? (
<Badge size="sm" color="gray"> <span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
{patient.patientType}
</Badge>
) : ( ) : (
<span className="text-sm text-placeholder"></span> <span className="text-sm text-quaternary"></span>
)} )}
</Table.Cell> </Table.Cell>
@@ -223,48 +211,12 @@ export const PatientsPage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
{/* Actions */}
<Table.Cell>
<div className="flex items-center gap-1">
{phone && (
<>
<ClickToCallButton
phoneNumber={phone}
size="sm"
label=""
/>
<button
onClick={() => window.open(`sms:+91${phone}`, '_self')}
title="SMS"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
</button>
<button
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
title="WhatsApp"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
</button>
</>
)}
<button
onClick={() => navigate(`/patient/${patient.id}`)}
title="View patient"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faEye} className="size-4" />
</button>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
</TableCard.Root>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3"> <div className="shrink-0 border-t border-secondary px-6 py-3">

View File

@@ -3,13 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { AgentTable } from '@/components/dashboard/agent-table';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import {
RichAgentTable,
TimeBreakdown,
NpsConversion,
PerformanceAlerts,
useSupervisorRollup,
} from '@/components/dashboard/supervisor-rollup';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
type DateRange = 'today' | 'week' | 'month'; type DateRange = 'today' | 'week' | 'month';
type DashboardTab = 'agents' | 'missed' | 'campaigns';
const getDateRangeStart = (range: DateRange): Date => { const getDateRangeStart = (range: DateRange): Date => {
const now = new Date(); const now = new Date();
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [tab, setTab] = useState<DashboardTab>('agents');
const [aiOpen, setAiOpen] = useState(true); const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
// date-range semantics — map them through directly.
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
const filteredCalls = useMemo(() => { const filteredCalls = useMemo(() => {
const rangeStart = getDateRangeStart(dateRange); const rangeStart = getDateRangeStart(dateRange);
return calls.filter((call) => { return calls.filter((call) => {
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month'; const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
const tabs = [ const convRate = useMemo(() => {
{ id: 'agents' as const, label: 'Agent Performance' }, if (filteredCalls.length === 0) return 0;
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` }, const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` }, return Math.round((completed / filteredCalls.length) * 100);
]; }, [filteredCalls]);
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
@@ -76,54 +87,48 @@ export const TeamDashboardPage = () => {
</div> </div>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main content */} {/* Main content — scrollable column with KPIs pinned at the
top, then stacked supervisor sections (Agent table, Time
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
No tabs: everything is scroll-visible so a supervisor
doesn't have to hunt across surfaces for their metrics. */}
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI cards — always visible */}
<div className="px-6 pt-5 pb-3"> <div className="px-6 pt-5 pb-3">
<DashboardKpi calls={filteredCalls} leads={leads} /> <DashboardKpi calls={filteredCalls} leads={leads} />
</div> </div>
{/* Tabs */} <div className="flex-1 space-y-5 px-6 pb-8">
<div className="flex items-center gap-1 border-b border-secondary px-6"> {loading && rollupAgents.length === 0 ? (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
tab === t.id
? "border-brand text-brand-secondary"
: "border-transparent text-tertiary hover:text-secondary",
)}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 p-6">
{loading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p> <p className="text-sm text-tertiary">Loading...</p>
</div> </div>
)}
{!loading && tab === 'agents' && (
<AgentTable calls={filteredCalls} />
)}
{!loading && tab === 'missed' && (
<MissedQueue calls={filteredCalls} />
)}
{!loading && tab === 'campaigns' && (
<div className="space-y-3">
{campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
) : ( ) : (
campaigns.map((c) => ( <>
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs"> <RichAgentTable agents={rollupAgents} />
<TimeBreakdown agents={rollupAgents} />
<NpsConversion agents={rollupAgents} convRate={convRate} />
<PerformanceAlerts agents={rollupAgents} />
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">
Missed Queue ({missedQueueCount})
</h3>
<MissedQueue calls={filteredCalls} />
</div>
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">
Campaigns ({campaigns.length})
</h3>
{campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
) : (
<div className="space-y-3">
{campaigns.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
<div> <div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span> <span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary"> <div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
@@ -139,9 +144,11 @@ export const TeamDashboardPage = () => {
</span> </span>
)} )}
</div> </div>
)) ))}
</div>
)} )}
</div> </div>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
type AgentPerf = { type AgentPerf = {
name: string; name: string;
ozonetelagentid: string; ozonetelAgentId: string;
npsscore: number | null; npsScore: number | null;
maxidleminutes: number | null; maxIdleMinutes: number | null;
minnpsthreshold: number | null; minNpsThreshold: number | null;
minconversionpercent: number | null; minConversionPercent: number | null;
calls: number; calls: number;
inbound: number; inbound: number;
missed: number; missed: number;
@@ -90,7 +90,7 @@ export const TeamPerformancePage = () => {
try { try {
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([ const [callsData, apptsData, 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 } } } }`, undefined, { silent: true }), 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>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }), apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, 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.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
@@ -110,9 +110,15 @@ export const TeamPerformancePage = () => {
let agentPerfs: AgentPerf[]; let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) { if (teamAgents.length > 0) {
// Real Ozonetel data available // Real Ozonetel data available — prefer authoritative agent
// relation (set by CDR enrichment), fall back to agentName
// string for rows not yet enriched.
agentPerfs = teamAgents.map((agent: any) => { agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); 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 agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -127,12 +133,12 @@ export const TeamPerformancePage = () => {
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0; const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return { return {
name: agent.name ?? agent.ozonetelagentid, name: agent.name ?? agent.ozonetelAgentId,
ozonetelagentid: agent.ozonetelagentid, ozonetelAgentId: agent.ozonetelAgentId,
npsscore: agent.npsscore, npsScore: agent.npsScore,
maxidleminutes: agent.maxidleminutes, maxIdleMinutes: agent.maxIdleMinutes,
minnpsthreshold: agent.minnpsthreshold, minNpsThreshold: agent.minNpsThreshold,
minconversionpercent: agent.minconversionpercent, minConversionPercent: agent.minConversionPercent,
calls: totalCalls, calls: totalCalls,
inbound, inbound,
missed, missed,
@@ -148,10 +154,23 @@ export const TeamPerformancePage = () => {
}; };
}); });
} else { } else {
// Fallback: build agent list from call records // Fallback: build agent list from call records. Prefer
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[]; // the authoritative agent relation; fall back to the raw
agentPerfs = agentNames.map((name) => { // agentName string (Ozonetel transfer chain) only when
const agentCalls = calls.filter((c: any) => c.agentName === name); // we have nothing better.
const byKey = new Map<string, { key: string; name: string; ozonetelAgentId: 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, ozonetelAgentId: c.agent.ozonetelAgentId });
} else if (c.agentName) {
byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName, ozonetelAgentId: c.agentName });
}
}
agentPerfs = Array.from(byKey.values()).map(({ key, name, ozonetelAgentId: _ozonetelAgentId }) => {
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 agentLeads = leads.filter((l: any) => l.assignedAgent === name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name); const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -159,11 +178,11 @@ export const TeamPerformancePage = () => {
return { return {
name, name,
ozonetelagentid: name, ozonetelAgentId: name,
npsscore: null, npsScore: null,
maxidleminutes: null, maxIdleMinutes: null,
minnpsthreshold: null, minNpsThreshold: null,
minconversionpercent: null, minConversionPercent: null,
calls: totalCalls, calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length, inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length, missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
@@ -215,17 +234,17 @@ export const TeamPerformancePage = () => {
xAxis: { type: 'category', data: days }, xAxis: { type: 'category', data: days },
yAxis: { type: 'value' }, yAxis: { type: 'value' },
series: [ series: [
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' }, { name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' }, { name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
], ],
}; };
}, [allCalls]); }, [allCalls]);
// NPS // NPS
const avgNps = useMemo(() => { const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsscore != null); const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0; if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length); return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]); }, [agents]);
const npsOption = useMemo(() => ({ const npsOption = useMemo(() => ({
@@ -246,13 +265,13 @@ export const TeamPerformancePage = () => {
const alerts = useMemo(() => { const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = []; const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) { for (const a of agents) {
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) { if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' }); list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
} }
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) { if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' }); list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
} }
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) { if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' }); list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
} }
} }
@@ -332,7 +351,7 @@ export const TeamPerformancePage = () => {
</Table.Header> </Table.Header>
<Table.Body items={agents}> <Table.Body items={agents}>
{(agent) => ( {(agent) => (
<Table.Row id={agent.ozonetelagentid || agent.name}> <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 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.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.inbound}</span></Table.Cell>
@@ -345,12 +364,12 @@ export const TeamPerformancePage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
<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')}> <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 ?? '—'} {agent.npsScore ?? '—'}
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}> <span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m {agent.idleMinutes}m
</span> </span>
</Table.Cell> </Table.Cell>
@@ -389,7 +408,7 @@ export const TeamPerformancePage = () => {
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map(agent => { {agents.map(agent => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1; const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes; const isHighIdle = agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes;
return ( return (
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}> <div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p> <p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
@@ -417,7 +436,7 @@ export const TeamPerformancePage = () => {
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-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> <h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
{agents.every(a => a.npsscore == null) ? ( {agents.every(a => a.npsScore == null) ? (
<div className="flex items-center justify-center py-8"> <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> <p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p>
</div> </div>
@@ -425,13 +444,13 @@ export const TeamPerformancePage = () => {
<> <>
<ReactECharts option={npsOption} style={{ height: 150 }} /> <ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2"> <div className="space-y-1 mt-2">
{agents.filter(a => a.npsscore != null).map(a => ( {agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2"> <div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span> <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="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 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> </div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span> <span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -36,6 +36,8 @@ export const TelephonySettingsPage = () => {
did: data.ozonetel?.did ?? '', did: data.ozonetel?.did ?? '',
sipId: data.ozonetel?.sipId ?? '', sipId: data.ozonetel?.sipId ?? '',
campaignName: data.ozonetel?.campaignName ?? '', campaignName: data.ozonetel?.campaignName ?? '',
adminUsername: data.ozonetel?.adminUsername ?? '',
adminPassword: data.ozonetel?.adminPassword ?? '',
}, },
sip: { sip: {
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com', domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',

View File

@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
// Disconnect SIP before logout // Disconnect SIP before logout
try { try {
disconnectSip(true); disconnectSip(true, 'logout');
} catch {} } catch {}
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens // Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
@@ -119,6 +119,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
await fetch(`${apiUrl}/auth/logout`, { await fetch(`${apiUrl}/auth/logout`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
keepalive: true, // survives page navigation — ensures session unlock completes
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
} catch (err) { } catch (err) {

View File

@@ -1,15 +1,15 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { import {
LEADS_QUERY, leadsQuery,
CAMPAIGNS_QUERY, campaignsQuery,
ADS_QUERY, adsQuery,
FOLLOW_UPS_QUERY, followUpsQuery,
LEAD_ACTIVITIES_QUERY, leadActivitiesQuery,
CALLS_QUERY, callsQuery,
APPOINTMENTS_QUERY, appointmentsQuery,
PATIENTS_QUERY, patientsQuery,
} from '@/lib/queries'; } from '@/lib/queries';
import { import {
transformLeads, transformLeads,
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
// These don't have platform entities yet — empty for now // These don't have platform entities yet — empty for now
const [templates] = useState<WhatsAppTemplate[]>([]); const [templates] = useState<WhatsAppTemplate[]>([]);
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
return; return;
} }
// Only flip the global loading flag on the very first fetch. Background
// polls refresh data in place so the UI doesn't flash "Loading..." —
// QA reported this as the supervisor surfaces randomly refreshing.
if (!hasLoadedRef.current) {
setLoading(true); setLoading(true);
}
setError(null); setError(null);
try { try {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null); const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
// Generic Relay pagination. Keeps paging until hasNextPage=false
// or we hit MAX_PAGES (guard against runaway loops on bad data).
// Returned shape mirrors the original single-page response so
// transformX functions work unchanged — they already read
// `{ <rootField>: { edges } }`.
const MAX_PAGES = 25;
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
const allEdges: any[] = [];
let after: string | undefined = undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const data: any = await gql<any>(builder(after));
if (!data) return null;
const root: any = data[rootField];
if (!root) break;
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
if (!root.pageInfo?.hasNextPage) break;
after = root.pageInfo.endCursor;
if (!after) break;
}
return { [rootField]: { edges: allEdges } };
};
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([ const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
gql<any>(LEADS_QUERY), fetchAll('leads', leadsQuery),
gql<any>(CAMPAIGNS_QUERY), fetchAll('campaigns', campaignsQuery),
gql<any>(ADS_QUERY), fetchAll('ads', adsQuery),
gql<any>(FOLLOW_UPS_QUERY), fetchAll('followUps', followUpsQuery),
gql<any>(LEAD_ACTIVITIES_QUERY), fetchAll('leadActivities', leadActivitiesQuery),
gql<any>(CALLS_QUERY), fetchAll('calls', callsQuery),
gql<any>(APPOINTMENTS_QUERY), fetchAll('appointments', appointmentsQuery),
gql<any>(PATIENTS_QUERY), fetchAll('patients', patientsQuery),
]); ]);
if (leadsData) setLeads(transformLeads(leadsData)); if (leadsData) setLeads(transformLeads(leadsData));
@@ -110,12 +138,19 @@ export const DataProvider = ({ children }: DataProviderProps) => {
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to load data'); setError(err.message ?? 'Failed to load data');
} finally { } finally {
hasLoadedRef.current = true;
setLoading(false); setLoading(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
// Poll every 30 seconds for fresh data (calls, leads, appointments)
const interval = setInterval(() => {
console.log('[DATA-PROVIDER] Polling for fresh data');
fetchData();
}, 30_000);
return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
const updateLead = (id: string, updates: Partial<Lead>) => { const updateLead = (id: string, updates: Partial<Lead>) => {

View File

@@ -12,6 +12,7 @@ import {
} from '@/state/sip-state'; } from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager'; import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { SIPConfig } from '@/types/sip'; import type { SIPConfig } from '@/types/sip';
// SIP config comes exclusively from the Agent entity (stored on login). // SIP config comes exclusively from the Agent entity (stored on login).
@@ -42,6 +43,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const setCallDuration = useSetAtom(sipCallDurationAtom); const setCallDuration = useSetAtom(sipCallDurationAtom);
const setCallStartTime = useSetAtom(sipCallStartTimeAtom); const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
// Register Jotai setters so the singleton SIP manager can update atoms // Register Jotai setters so the singleton SIP manager can update atoms
useEffect(() => { useEffect(() => {
@@ -50,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
setCallState, setCallState,
setCallerNumber, setCallerNumber,
setCallUcid, setCallUcid,
setIsMuted: setIsMutedGlobal,
setIsOnHold: setIsOnHoldGlobal,
}); });
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
// Auto-connect SIP on mount — only if Agent entity has SIP config // Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => { useEffect(() => {
@@ -125,14 +130,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
} }
}; };
const handleUnload = () => disconnectSip(true); const handleUnload = () => disconnectSip(true, 'page-unload');
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('unload', handleUnload); window.addEventListener('unload', handleUnload);
return () => { return () => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('unload', handleUnload); window.removeEventListener('unload', handleUnload);
disconnectSip(true); // force — component is unmounting disconnectSip(true, 'sip-provider-unmount'); // force — component is unmounting
}; };
}, []); // empty deps — runs once on mount, cleanup only on unmount }, []); // empty deps — runs once on mount, cleanup only on unmount
@@ -156,6 +161,17 @@ export const useSip = () => {
// Ozonetel outbound dial — single path for all outbound calls // Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => { const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
// Hard guard — no dial is valid when SIP isn't registered, because
// the audio leg can't be established. Every entry point (worklist
// row, click-to-call, phone-action-cell, patient 360, etc.) funnels
// through this callback, so gating here is the single source of
// truth for "can this agent place a call right now?"
if (connectionStatus !== 'registered') {
notify.error('Telephony unavailable', 'Cannot place call — SIP is not registered. Check your connection.');
console.warn(`[DIAL] Blocked — SIP not registered (status=${connectionStatus})`);
return;
}
// Block outbound calls when agent is on Break or Training // Block outbound calls when agent is on Break or Training
const agentCfg = localStorage.getItem('helix_agent_config'); const agentCfg = localStorage.getItem('helix_agent_config');
if (agentCfg) { if (agentCfg) {
@@ -166,7 +182,6 @@ export const useSip = () => {
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`); const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
const stateData = await stateRes.json(); const stateData = await stateRes.json();
if (stateData.state === 'break' || stateData.state === 'training') { if (stateData.state === 'break' || stateData.state === 'training') {
const { notify } = await import('@/lib/toast');
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls'); notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
return; return;
} }
@@ -204,7 +219,7 @@ export const useSip = () => {
setCallerNumber(null); setCallerNumber(null);
throw new Error('Dial failed'); throw new Error('Dial failed');
} }
}, [setCallState, setCallerNumber, setCallUcid]); }, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
const answer = useCallback(() => getSipClient()?.answer(), []); const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []); const reject = useCallback(() => getSipClient()?.reject(), []);

View File

@@ -13,6 +13,8 @@ type StateUpdater = {
setCallState: (state: CallState) => void; setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void; setCallerNumber: (number: string | null) => void;
setCallUcid: (ucid: string | null) => void; setCallUcid: (ucid: string | null) => void;
setIsMuted: (muted: boolean) => void;
setIsOnHold: (onHold: boolean) => void;
}; };
let stateUpdater: StateUpdater | null = null; let stateUpdater: StateUpdater | null = null;
@@ -83,6 +85,13 @@ export function connectSip(config: SIPConfig): void {
if (ucid) stateUpdater?.setCallUcid(ucid); if (ucid) stateUpdater?.setCallUcid(ucid);
if (state === 'ended' || state === 'failed') { if (state === 'ended' || state === 'failed') {
// Reset both the SIP track AND the Recoil state — otherwise the
// UI icon + toggle-mute branch logic stay "muted" and the next
// call opens in a confusing half-muted state.
sipClient?.unmute();
sipClient?.unhold();
stateUpdater?.setIsMuted(false);
stateUpdater?.setIsOnHold(false);
outboundActive = false; outboundActive = false;
outboundPending = false; outboundPending = false;
} }
@@ -92,16 +101,16 @@ export function connectSip(config: SIPConfig): void {
sipClient.connect(); sipClient.connect();
} }
export function disconnectSip(force = false): void { export function disconnectSip(force = false, reason = 'unspecified'): void {
// Guard: don't disconnect SIP during an active or pending call // Guard: don't disconnect SIP during an active or pending call
// unless explicitly forced (e.g., logout, page unload). // unless explicitly forced (e.g., logout, page unload).
// This prevents React re-render cycles from killing the // This prevents React re-render cycles from killing the
// SIP WebSocket mid-dial. // SIP WebSocket mid-dial.
if (!force && (outboundPending || outboundActive)) { if (!force && (outboundPending || outboundActive)) {
console.log('[SIP-MGR] Disconnect blocked — call in progress'); console.log(`[SIP-MGR] Disconnect blocked — call in progress (reason=${reason})`);
return; return;
} }
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : '')); console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
sipClient?.disconnect(); sipClient?.disconnect();
sipClient = null; sipClient = null;
connected = false; connected = false;

View File

@@ -250,11 +250,15 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL'; export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
export type CallDisposition = export type CallDisposition =
| 'APPOINTMENT_BOOKED' | 'APPOINTMENT_BOOKED'
| 'APPOINTMENT_RESCHEDULED'
| 'APPOINTMENT_CANCELLED'
| 'FOLLOW_UP_SCHEDULED' | 'FOLLOW_UP_SCHEDULED'
| 'INFO_PROVIDED' | 'INFO_PROVIDED'
| 'WRONG_NUMBER' | 'WRONG_NUMBER'
| 'NO_ANSWER' | 'NO_ANSWER'
| 'CALLBACK_REQUESTED'; | 'NOT_INTERESTED'
| 'CALLBACK_REQUESTED'
| 'CALL_DROPPED';
export type Call = { export type Call = {
id: string; id: string;
@@ -273,6 +277,12 @@ export type Call = {
appointmentId: string | null; appointmentId: string | null;
leadId: string | null; leadId: string | null;
sla?: number | null; sla?: number | null;
// Authoritative agent link from CDR enrichment. agentName remains the
// raw Ozonetel string (may be a transfer chain) for display fallback.
agentId?: string | null;
agent?: { id: string; name: string | null; ozonetelAgentId: string | null } | null;
transferredTo?: string | null;
transferType?: string | null;
// Denormalized for display // Denormalized for display
leadName?: string; leadName?: string;
leadPhone?: string; leadPhone?: string;
@@ -313,6 +323,7 @@ export type Appointment = {
patientId: string | null; patientId: string | null;
patientName: string | null; patientName: string | null;
patientPhone: string | null; patientPhone: string | null;
clinicId: string | null;
clinicName: string | null; clinicName: string | null;
}; };