31 Commits

Author SHA1 Message Date
eee7c82b8d merge: hardening/apr-week3 → master (v0.13-ai-coaching)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
AI coaching panel:
- 3-zone panel: summary card, rule-driven suggestions, contextual chat
- Structured JSON responses via Output.object schema enforcement
- Suggestions below chat, no raw JSON during streaming
- P360 tab toggle removed — single coaching surface
- Design spec + implementation plan committed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:11:31 +05:30
45 changed files with 3821 additions and 1480 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

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

View File

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

View File

@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
onApply?: () => void;
/** The function to call when the cancel button is clicked. */
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({
month: "short",
day: "numeric",
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
</AriaGroup>
<AriaPopover
offset={8}
placement="bottom start"
placement={popoverPlacement ?? "bottom start"}
shouldFlip
className={({ isEntering, isExiting }) =>
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 {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus,
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai';
@@ -13,10 +14,11 @@ import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider';
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 { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format';
import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state';
@@ -44,6 +46,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
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 [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false);
@@ -62,6 +68,39 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
});
};
// 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);
@@ -300,7 +339,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current}
@@ -335,14 +376,84 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}}
/>
)}
{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
key={`${editingApptId ?? 'new'}-${apptMode}`}
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) { setEditingApptId(null); setApptMode('edit'); }
}}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? 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
@@ -362,6 +473,58 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
)}
</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 */}
<DispositionModal
isOpen={dispositionOpen}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
@@ -6,7 +6,7 @@ import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Button } from '@/components/base/buttons/button';
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 { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
@@ -35,6 +35,11 @@ type AppointmentFormProps = {
// CANCELLED each map to distinct disposition outcomes).
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
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 };
@@ -60,6 +65,7 @@ export const AppointmentForm = ({
patientId,
onSaved,
existingAppointment,
readOnly = false,
}: AppointmentFormProps) => {
const isEditMode = !!existingAppointment;
@@ -140,7 +146,12 @@ export const AppointmentForm = ({
setTimeSlotItems(autoItems);
}
}).catch(() => setTimeSlotItems([]));
}, [doctor, date, clinic, timeSlot]);
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
// deliberately excluded. Including clinic causes a loop: the effect calls
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
// timeSlot is only needed for the synthetic "current" option injection which
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
}, [doctor, date]);
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -236,13 +247,25 @@ export const AppointmentForm = ({
const filteredDoctors = department
? doctors.filter(d => d.department === department)
: 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,
isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
}));
})), [timeSlotItems, bookedSlots]);
const handleSave = async () => {
if (!date || !timeSlot || !doctor || !department) {
@@ -471,7 +494,7 @@ export const AppointmentForm = ({
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isDisabled={!isNameEditable}
isDisabled={readOnly || !isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
@@ -544,7 +567,7 @@ export const AppointmentForm = ({
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0}
isDisabled={readOnly || doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
@@ -555,7 +578,7 @@ export const AppointmentForm = ({
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department}
isDisabled={readOnly || !department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
@@ -567,7 +590,12 @@ export const AppointmentForm = ({
value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')}
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>
@@ -585,7 +613,7 @@ export const AppointmentForm = ({
<button
key={slot.id}
type="button"
disabled={isBooked}
disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)}
className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
@@ -613,6 +641,7 @@ export const AppointmentForm = ({
placeholder="Describe the reason for visit..."
value={chiefComplaint}
onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2}
/>
@@ -649,7 +678,7 @@ export const AppointmentForm = ({
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div>
{isEditMode && (
{isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment
</Button>
@@ -659,9 +688,11 @@ export const AppointmentForm = ({
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
{!readOnly && (
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
)}
</div>
</div>

View File

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

View File

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

View File

@@ -1,13 +1,9 @@
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 { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table';
const SearchLg = faIcon(faMagnifyingGlass);
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 { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast';
@@ -36,6 +32,7 @@ type WorklistFollowUp = {
followUpStatus: string | null;
scheduledAt: string | null;
priority: string | null;
patientId?: string | null;
patientName?: string;
patientPhone?: string;
};
@@ -53,18 +50,34 @@ type MissedCall = {
callSourceNumber: string | null;
missedCallCount: number | null;
callbackAttemptedAt: string | null;
campaign?: { id: string; campaignName: string } | null;
};
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 {
missedCalls: MissedCall[];
followUps: WorklistFollowUp[];
leads: WorklistLead[];
loading: boolean;
onSelectLead: (lead: WorklistLead) => void;
selectedLeadId: string | null;
onSelectItem: (selection: WorklistSelection) => void;
selectedItemId: string | null;
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';
@@ -82,6 +95,7 @@ type WorklistRow = {
createdAt: string;
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
leadId: string | null;
patientId: string | null;
originalLead: WorklistLead | null;
lastContactedAt: string | null;
contactAttempts: number;
@@ -163,17 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
const formatDisposition = (disposition: string): string =>
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatSource = (source: string): string => {
const map: Record<string, string> = {
FACEBOOK_AD: 'Facebook',
GOOGLE_AD: 'Google',
WALK_IN: 'Walk-in',
REFERRAL: 'Referral',
WEBSITE: 'Website',
PHONE_INQUIRY: 'Phone',
};
return map[source] ?? source.replace(/_/g, ' ');
};
// formatSource + formatDid kept for reference but no longer rendered
// in the table — SOURCE/BRANCH column removed from display per user
// request. Data stays on the row for future use.
const IconInbound = faIcon(faPhoneArrowDown);
const IconOutbound = faIcon(faPhoneArrowUp);
@@ -200,10 +206,14 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: call.createdAt,
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId,
patientId: (call as any).patientId ?? null,
originalLead: null,
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
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,
missedCallId: call.id,
});
@@ -234,6 +244,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
leadId: null,
patientId: fu.patientId ?? null,
originalLead: null,
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
contactAttempts: 0,
@@ -261,6 +272,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: lead.createdAt,
taskState: 'PENDING',
leadId: lead.id,
patientId: (lead as any).patientId ?? null,
originalLead: lead,
lastContactedAt: lead.lastContacted ?? null,
contactAttempts: lead.contactAttempts ?? 0,
@@ -286,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
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 [search, setSearch] = useState('');
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
// sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist.
@@ -389,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15;
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 handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -423,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
<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>
{/* Filter pills — custom buttons matching All Leads pattern */}
<div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => handleTabChange(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>
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
@@ -459,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="name" label="PATIENT" allowsSorting />
<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.Header>
<Table.Body items={pagedRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
const isSelected = row.id === selectedItemId;
// Sub-line: last interaction context
const subLine = row.lastContactedAt
@@ -481,7 +492,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
isSelected && 'bg-brand-primary',
)}
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>
@@ -532,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<span className="text-xs text-quaternary italic">No phone</span>
)}
</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>
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}

View File

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

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) =>
updateHoliday(idx, { date: dv ? dv.toString() : '' })
}
// Holidays must be today or in the future — you
// can't observe a holiday that already passed.
minValue={today(getLocalTimeZone())}
/>
</div>
<div className="flex-1">

View File

@@ -8,14 +8,13 @@ import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { GlobalSearch } from '@/components/shared/global-search';
// import { GlobalSearch } from '@/components/shared/global-search';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
@@ -25,7 +24,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation();
const { isCCAgent, isAdmin } = useAuth();
const { isCCAgent } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus();
@@ -118,31 +117,25 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && (
{/* Agent top bar — network indicator + status toggle (agents only) */}
{hasAgentConfig && (
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
<GlobalSearch />
<div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />}
{hasAgentConfig && (
<>
<div className={cx(
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
networkQuality === 'good'
? 'bg-success-primary text-success-primary'
: networkQuality === 'offline'
? 'bg-error-secondary text-error-primary'
: 'bg-warning-secondary text-warning-primary',
)}>
<FontAwesomeIcon
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
className="size-3"
/>
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
<div className={cx(
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
networkQuality === 'good'
? 'bg-success-primary text-success-primary'
: networkQuality === 'offline'
? 'bg-error-secondary text-error-primary'
: 'bg-warning-secondary text-warning-primary',
)}>
<FontAwesomeIcon
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
className="size-3"
/>
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</div>
</div>
)}

View File

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

View File

@@ -12,10 +12,10 @@ import {
faHospitalUser,
faCalendarCheck,
faPhone,
faAddressBook,
faUsers,
faArrowRightFromBracket,
faTowerBroadcast,
faChartLine,
faFileAudio,
faPhoneMissed,
} 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 { Avatar } from "@/components/base/avatar/avatar";
import { useAuth } from "@/providers/auth-provider";
import { useUiFlags } from "@/hooks/use-ui-flags";
import { useAgentState } from "@/hooks/use-agent-state";
import { useThemeTokens } from "@/providers/theme-token-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
@@ -44,12 +45,12 @@ const IconCommentDots = faIcon(faCommentDots);
const IconChartMixed = faIcon(faChartMixed);
const IconGear = faIcon(faGear);
const IconPhone = faIcon(faPhone);
const IconAddressBook = faIcon(faAddressBook);
const IconClockRewind = faIcon(faClockRotateLeft);
const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser);
const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
@@ -62,12 +63,16 @@ const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') {
return [
{ 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: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
]},
{ label: 'Data & Reports', items: [
{ label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
@@ -91,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone },
{ 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: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
@@ -102,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Main', items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
@@ -149,7 +157,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
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 = (
<aside

View File

@@ -1,17 +1,14 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
@@ -25,6 +22,7 @@ type LeadTableProps = {
onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void;
visibleColumns?: Set<string>;
selectionMode?: 'multiple' | 'none';
};
type TableRow = {
@@ -55,6 +53,7 @@ export const LeadTable = ({
onSort,
onViewActivity,
visibleColumns,
selectionMode,
}: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
@@ -95,6 +94,7 @@ export const LeadTable = ({
}, [leads, expandedDupId]);
const allColumns = [
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
@@ -107,18 +107,17 @@ export const LeadTable = ({
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
];
const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
: allColumns;
return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
<Table
aria-label="Leads table"
selectionMode="multiple"
selectionMode={selectionMode ?? 'multiple'}
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
@@ -143,6 +142,7 @@ export const LeadTable = ({
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || '\u2014';
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
const phone = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: '\u2014';
@@ -156,6 +156,7 @@ export const LeadTable = ({
id={row.id}
className="bg-warning-primary"
>
<Table.Cell />
<Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span>
</Table.Cell>
@@ -189,17 +190,6 @@ export const LeadTable = ({
<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>
);
}
@@ -217,12 +207,26 @@ export const LeadTable = ({
key={row.id}
id={row.id}
className={cx(
'group/row',
isSpamRow && !isSelected && 'bg-warning-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>
<span className="font-semibold text-primary">{phone}</span>
{phoneRaw ? (
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>}
{isCol('name') && <Table.Cell>
<span className="text-secondary">{name}</span>
@@ -306,15 +310,6 @@ export const LeadTable = ({
<span className="text-tertiary">0</span>
)}
</Table.Cell>}
<Table.Cell>
<Button
size="sm"
color="tertiary"
iconLeading={DotsVertical}
aria-label="Row actions"
onClick={() => onViewActivity?.(lead)}
/>
</Table.Cell>
</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 { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
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 { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
@@ -20,9 +21,14 @@ type MaintAction = {
label: string;
description: string;
needsPreStep?: boolean;
agentPickerEndpoint?: 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 = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
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 () => {
if (!action || otp.length < 6) return;
@@ -43,44 +98,49 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null);
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) {
// Client-side action — OTP verified by calling a dummy maint endpoint first
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
});
if (!otpRes.ok) {
setError('Invalid maintenance code');
const { ok, data } = await postMaint('force-ready', {}, otp);
if (!ok) {
setError(data.message ?? 'Invalid maintenance code');
setLoading(false);
return;
}
const result = await action.clientSideHandler(preStepPayload);
notify.success(action.label, result.message ?? 'Completed');
onOpenChange(false);
setOtp('');
} else {
// Standard sidecar endpoint — include agentId from agent config
const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
reset();
return;
}
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
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');
onOpenChange(false);
setOtp('');
} else {
setError(data.message ?? 'Failed');
}
// Default: single-shot endpoint with agentId from the CC agent's
// own local config (cc-agent context). Supervisors hitting this
// path without agent config used to get 400 — the agent-picker
// branch above is the fix.
const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
const { ok, data } = await postMaint(action.endpoint, payload, otp);
if (ok) {
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
reset();
} else {
setError(data.message ?? 'Failed');
}
} catch {
setError('Request failed');
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null);
};
const handleClose = () => {
onOpenChange(false);
setOtp('');
setError(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 (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-[400px]">
<Modal className="sm:max-w-[440px]">
<Dialog>
{() => (
<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>
{/* Pre-step content (e.g., campaign selection) */}
{action.needsPreStep && preStepContent && (
{action.needsPreStep && preStepContent && !showPicker && (
<div className="px-6 pb-4">
{preStepContent}
</div>
)}
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
{showOtp && (
<div className="flex flex-col items-center gap-2 px-6 pb-5">
<PinInput size="sm">
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</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 */}
<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">
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
<Button
size="md"
color="primary"
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
isDisabled={confirmDisabled}
isLoading={loading}
onClick={handleSubmit}
onClick={handleConfirm}
className="flex-1"
>
Confirm

View File

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

View File

@@ -10,41 +10,53 @@ type SectionCardProps = {
description: string;
icon: any;
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;
disabled?: boolean;
};
// Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
// dedicated page. The status badge mirrors the wizard's setup-state so an
// admin can see at a glance which sections still need attention.
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
// its dedicated page or triggers a parent-owned callback.
export const SectionCard = ({
title,
description,
icon,
iconColor = 'text-brand-primary',
href,
onClick,
status = 'unknown',
disabled = false,
}: SectionCardProps) => {
return (
<Link
to={href}
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
>
const className = cx(
'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
disabled
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
: 'bg-primary hover:border-brand hover:shadow-md',
);
const body = (
<>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-primary">{title}</h3>
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
<p className="mt-1 text-xs text-tertiary">{description}</p>
</div>
</div>
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
{!disabled && (
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
)}
</div>
{status !== 'unknown' && (
@@ -62,6 +74,26 @@ export const SectionCard = ({
)}
</div>
)}
</>
);
if (disabled) {
return (
<div className={className}>
{body}
</div>
);
}
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
return (
<Link to={href ?? '#'} className={className}>
{body}
</Link>
);
};

View File

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

View File

@@ -5,6 +5,10 @@ export type MaintAction = {
label: string;
description: string;
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 }>;
};
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
endpoint: 'force-ready',
label: 'Force Ready',
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
agentPickerEndpoint: 'session-status',
},
unlockAgent: {
endpoint: 'unlock-agent',
label: 'Unlock Agent',
description: 'Release the Redis session lock so the agent can log in again.',
agentPickerEndpoint: 'session-status',
},
backfill: {
endpoint: 'backfill-missed-calls',

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 { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type MissedCall = {
id: string;
@@ -133,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => {
useEffect(() => {
fetchWorklist();
// Refresh every 30 seconds
const interval = setInterval(fetchWorklist, 30000);
return () => clearInterval(interval);
// SSE stream for instant worklist updates. No polling fallback —
// if SSE breaks, the worklist stops updating and we fix the SSE,
// 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]);
return { ...data, loading, error, refresh: fetchWorklist };

View File

@@ -1,5 +1,36 @@
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 = {
headers: string[];
rows: CSVRow[];

View File

@@ -1,7 +1,21 @@
// 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
contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
firstContacted lastContacted contactAttempts convertedAt
patientId campaignId
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
campaignName typeCustom status platform
startDate endDate
@@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated
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
adName externalAdId status format
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
impressions clicks conversions
spend { amountMicros currencyCode }
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
typeCustom status scheduledAt completedAt
priority assignedAgent
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
activityType summary occurredAt performedBy
previousValue newValue
channel durationSec outcome
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
direction callStatus callerNumber { primaryPhoneNumber } agentName
startedAt endedAt durationSec
@@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
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 }
department specialty qualifications yearsOfExperience
visitingHours
@@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
active registrationNumber
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 fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

View File

@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
import { AuthGuard } from "@/components/layout/auth-guard";
import { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard";
import { useUiFlags } from "@/hooks/use-ui-flags";
const AdminSetupGuard = () => {
const { isAdmin } = useAuth();
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 { NotFound } from "@/pages/not-found";
import { AllLeadsPage } from "@/pages/all-leads";
@@ -28,12 +43,14 @@ import { OutreachPage } from "@/pages/outreach";
import { Patient360Page } from "@/pages/patient-360";
import { ReportsPage } from "@/pages/reports";
import { PatientsPage } from "@/pages/patients";
import { ContactsPage } from "@/pages/contacts";
import { TeamDashboardPage } from "@/pages/team-dashboard";
import { IntegrationsPage } from "@/pages/integrations";
import { AgentDetailPage } from "@/pages/agent-detail";
import { SettingsPage } from "@/pages/settings";
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 { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings";
@@ -88,6 +105,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/call-history" element={<CallHistoryPage />} />
<Route path="/my-performance" element={<MyPerformancePage />} />
<Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} />
{/* Admin-only routes */}
@@ -99,13 +117,15 @@ createRoot(document.getElementById("root")!).render(
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} />
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
<Route element={<RequireSelfServeSetup />}>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} />
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
</Route>
</Route>
<Route path="/agent/:id" element={<AgentDetailPage />} />

View File

@@ -1,29 +1,33 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { useSearchParams } from 'react-router';
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 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 { 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 { TopBar } from '@/components/layout/top-bar';
import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table';
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 { AssignModal } from '@/components/modals/assign-modal';
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
// Bulk action modals removed — checkboxes hidden
// import { AssignModal } from '@/components/modals/assign-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 { useLeads } from '@/hooks/use-leads';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
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';
type TabKey = 'new' | 'my-leads' | 'all';
@@ -38,27 +42,30 @@ const PAGE_SIZE = 15;
export const AllLeadsPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialSource = searchParams.get('source') as LeadSource | null;
const [tab, setTab] = useState<TabKey>('new');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
const myLeadsOnly = tab === 'my-leads';
const statusFilter: LeadStatus | undefined = undefined;
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,
excludeSources: CONTACT_SOURCES,
status: statusFilter,
search: searchQuery || undefined,
});
const { agents, templates, leadActivities, campaigns } = useData();
const { leadActivities, campaigns } = useData();
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
const columnDefs = [
@@ -160,15 +167,47 @@ export const AllLeadsPage = () => {
setCurrentPage(1);
};
const handleTabChange = (key: string | number) => {
setTab(key as TabKey);
setCurrentPage(1);
setSelectedIds([]);
const handleExportCsv = () => {
// Export exactly what the user currently sees — same filters, same
// 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) => {
setCurrentPage(page);
setSelectedIds([]);
};
// Build active filters for pills display
@@ -192,27 +231,6 @@ export const AllLeadsPage = () => {
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
const [activityLead, setActivityLead] = useState<Lead | null>(null);
@@ -225,30 +243,12 @@ export const AllLeadsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="All Leads" subtitle={`${total} total`} />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + Controls row */}
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
<div className="flex items-center gap-3">
<Button
onClick={() => navigate(-1)}
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back"
/>
<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">
<PageHeader
title="All Leads"
subtitle={`${total} total`}
infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
controls={
<>
<div className="w-56">
<Input
placeholder="Search leads..."
@@ -267,11 +267,15 @@ export const AllLeadsPage = () => {
size="sm"
color="secondary"
iconLeading={Download01}
onClick={handleExportCsv}
>
Export CSV
</Button>
</div>
</div>
</>
}
/>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Active filters */}
{activeFilters.length > 0 && (
@@ -330,25 +334,13 @@ export const AllLeadsPage = () => {
</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 */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
<LeadTable
leads={pagedLeads}
onSelectionChange={setSelectedIds}
selectedIds={selectedIds}
onSelectionChange={() => {}}
selectedIds={[]}
selectionMode="none"
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
@@ -369,52 +361,6 @@ export const AllLeadsPage = () => {
)}
</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 */}
{activityLead && (
<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

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
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 { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider';
@@ -8,26 +8,33 @@ import { useData } from '@/providers/data-provider';
import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider';
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 type { ContextPanelSubject } from '@/components/call-desk/context-panel';
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 { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const SearchLg = faIcon(faMagnifyingGlass);
export const CallDeskPage = () => {
const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
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 [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false);
const [search, setSearch] = useState('');
// DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom);
@@ -108,14 +115,22 @@ export const CallDeskPage = () => {
}
}, [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
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
patientId: resolvedCaller.patientId,
}
? workLead
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
: {
id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
patientId: resolvedCaller.patientId,
}
: callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null;
@@ -126,16 +141,59 @@ export const CallDeskPage = () => {
: selectedLead;
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 (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<div className="flex items-center gap-3">
{/* Header — matches PageHeader visual pattern */}
<div className="flex shrink-0 items-center justify-between px-6 py-3">
<div className="flex items-center gap-2">
<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 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 ? (
<button
onClick={startSimCall}
@@ -242,9 +300,10 @@ export const CallDeskPage = () => {
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
onSelectItem={handleSelectItem}
selectedItemId={selectedItemId}
onDialMissedCall={(id) => setActiveMissedCallId(id)}
search={search}
/>
)}
</div>

View File

@@ -11,28 +11,35 @@ import {
} from '@fortawesome/pro-duotone-svg-icons';
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 { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input';
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 { computeSlaStatus } from '@/lib/scoring';
import { cx } from '@/utils/cx';
// cx removed — no longer used after SLA column removal
import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider';
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
const filterItems = [
const allFilterItems = [
{ id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' },
{ 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' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
@@ -54,13 +61,6 @@ const formatDuration = (seconds: number | null): string => {
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 }) => {
if (status === 'MISSED') {
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
@@ -71,12 +71,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
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 audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -88,8 +82,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
audio.pause();
setIsPlaying(false);
} else {
audio.play().catch(() => setIsPlaying(false));
setIsPlaying(true);
audio.play().then(() => setIsPlaying(true)).catch(() => {});
}
};
@@ -119,11 +112,11 @@ const PAGE_SIZE = 20;
export const CallHistoryPage = () => {
const { calls, leads } = useData();
const { user, isAdmin } = useAuth();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterKey>('all');
const [page, setPage] = useState(1);
// Build a map of lead names by ID for enrichment
const leadNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const lead of leads) {
@@ -135,7 +128,10 @@ export const CallHistoryPage = () => {
return map;
}, [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(() => {
let result = [...calls].sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
@@ -143,79 +139,95 @@ export const CallHistoryPage = () => {
return dateB - dateA;
});
// Direction / status filter. "Inbound" shows answered inbound only — missed
// calls have their own dedicated filter so they don't double-appear.
// CC agent: filter to own calls only.
// 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 === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
// Search filter
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter((c) => {
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
const phone = c.callerNumber?.[0]?.number ?? '';
const agent = c.agentName ?? '';
return (
name.toLowerCase().includes(q) ||
phone.includes(q) ||
agent.toLowerCase().includes(q)
);
return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
});
}
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 totalPages = Math.max(1, Math.ceil(filteredCalls.length / 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
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden p-7">
<TableCard.Root size="md" className="flex-1 min-h-0">
<TableCard.Header
title="Call History"
badge={String(filteredCalls.length)}
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
contentTrailing={
<div className="flex items-center gap-2">
<div className="w-44">
<Select
size="sm"
placeholder="All Calls"
selectedKey={filter}
onSelectionChange={(key) => setFilter(key as FilterKey)}
items={filterItems}
aria-label="Filter calls"
>
{(item) => (
<Select.Item id={item.id} label={item.label}>
{item.label}
</Select.Item>
)}
</Select>
</div>
<div className="w-56">
<Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
<PageHeader
title={isAdmin ? 'Call History' : 'My Call History'}
badge={filteredCalls.length}
subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
controls={
<>
<div className="w-44">
<Select
size="sm"
placeholder="All Calls"
selectedKey={filter}
onSelectionChange={(key) => setFilter(key as FilterKey)}
items={isAdmin ? allFilterItems : agentFilterItems}
aria-label="Filter calls"
>
{(item) => (
<Select.Item id={item.id} label={item.label}>
{item.label}
</Select.Item>
)}
</Select>
</div>
}
/>
<div className="w-56">
<Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
</>
}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{filteredCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
@@ -231,18 +243,15 @@ export const CallHistoryPage = () => {
<Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" />
<Table.Head label="SLA" className="w-24" />
<Table.Head label="AGENT" />
<Table.Head label="RECORDING" className="w-24" />
{/* Agent columns — only visible for supervisor */}
{isAdmin && <Table.Head label="AGENT" />}
{isAdmin && <Table.Head label="RECORDING" className="w-24" />}
<Table.Head label="TIME" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={pagedCalls}>
{(call) => {
const phoneRawForName = call.callerNumber?.[0]?.number ?? '';
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRawForName ? formatPhone({ number: phoneRawForName, callingCode: '+91' }) : 'Unknown');
const phoneDisplay = formatPhoneDisplay(call);
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;
return (
@@ -256,9 +265,14 @@ export const CallHistoryPage = () => {
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{phoneDisplay}
</span>
{phoneRaw ? (
<PhoneActionCell
phoneNumber={phoneRaw}
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
/>
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary whitespace-nowrap">
@@ -274,68 +288,43 @@ export const CallHistoryPage = () => {
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<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>
<Table.Cell>
<span className="text-sm text-secondary">
{call.agentName ?? '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
{call.recordingUrl ? (
<RecordingPlayer url={call.recordingUrl} />
) : (
<span className="text-xs text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<span className="text-sm text-secondary">
{call.agent?.name ?? call.agentName ?? '\u2014'}
</span>
</Table.Cell>
)}
{isAdmin && (
<Table.Cell>
{call.recordingUrl ? (
<RecordingPlayer url={call.recordingUrl} />
) : (
<span className="text-xs text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
)}
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
</span>
</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.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</TableCard.Root>
</div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
)}
</div>
);
};

View File

@@ -9,7 +9,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table';
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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
@@ -26,6 +26,7 @@ type RecordingRecord = {
callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
agent: { id: string; name: string; ozonetelAgentId: string } | null;
startedAt: string | null;
durationSec: number | null;
disposition: string | null;
@@ -35,7 +36,8 @@ type RecordingRecord = {
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition sla
agentName agent { id name ozonetelAgentId }
startedAt durationSec disposition sla
recording { primaryLinkUrl primaryLinkLabel }
} } } }`;
@@ -76,13 +78,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
};
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: 'ai', label: 'AI', defaultVisible: true },
{ id: 'type', label: 'Type', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
{ id: 'duration', label: 'Duration', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
{ id: 'recording', label: 'Recording', defaultVisible: true },
];
@@ -96,6 +98,85 @@ export const CallRecordingsPage = () => {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
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.agent?.name ?? 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(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => {
@@ -128,7 +209,7 @@ export const CallRecordingsPage = () => {
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(c =>
(c.agentName ?? '').toLowerCase().includes(q) ||
(c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.disposition ?? '').toLowerCase().includes(q),
);
@@ -138,7 +219,7 @@ export const CallRecordingsPage = () => {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
result = [...result].sort((a, b) => {
switch (sortDescriptor.column) {
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
case 'agent': return (a.agent?.name ?? a.agentName ?? '').localeCompare(b.agent?.name ?? b.agentName ?? '') * dir;
case 'dateTime': {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
@@ -159,19 +240,20 @@ export const CallRecordingsPage = () => {
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
return (
<>
<TopBar title="Call Recordings" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
<div className="flex items-center gap-3">
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="Call Recordings"
badge={filtered.length}
infoText="All call recordings with AI analysis, dispositions, and playback."
controls={
<>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</div>
</div>
</>
}
/>
{/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
@@ -185,120 +267,28 @@ export const CallRecordingsPage = () => {
</div>
) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header>
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
<Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header columns={activeColumns}>
{(col) => (
<Table.Head
key={col.id}
id={col.id}
label={col.label}
isRowHeader={col.isRowHeader}
allowsSorting={col.allowsSorting}
/>
)}
</Table.Header>
<Table.Body items={pagedRows}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
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.Row>
);
}}
{(call) => (
<Table.Row id={call.id} columns={activeColumns} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>
@@ -335,7 +325,6 @@ export const CallRecordingsPage = () => {
/>
);
})()}
</div>
</>
</div>
);
};

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { useParams } from 'react-router';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignHero } from '@/components/campaigns/campaign-hero';
import { KpiStrip } from '@/components/campaigns/kpi-strip';
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 { BudgetBar } from '@/components/campaigns/budget-bar';
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 { useLeads } from '@/hooks/use-leads';
import { useData } from '@/providers/data-provider';
import { formatCurrency, formatDateOnly } from '@/lib/format';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'leads', label: 'Leads' },
];
import type { Lead } from '@/types/entities';
export const CampaignDetailPage = () => {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const { campaigns, ads } = useCampaigns();
const { leads } = useLeads();
const { leadActivities } = useData();
const campaign = campaigns.find((c) => c.id === id);
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, 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) {
return (
<div className="flex flex-1 items-center justify-center p-8">
@@ -46,126 +69,122 @@ export const CampaignDetailPage = () => {
return (
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Hero header */}
<CampaignHero campaign={campaign} />
{/* KPI strip */}
<KpiStrip campaign={campaign} />
{/* Tabs */}
<div className="px-7 pt-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
<TabList
type="underline"
size="sm"
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">
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
<div className="px-7 pt-5 pb-7">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
<div className="space-y-6">
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-md font-bold text-primary">
Leads ({campaignLeads.length})
</h3>
</div>
{campaignLeads.length === 0 ? (
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
No leads from this campaign yet.
</div>
) : (
<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>
{campaignAds.map((ad) => (
<AdCard key={ad.id} ad={ad} />
))}
{campaignAds.length === 0 && (
<p className="py-8 text-center text-sm text-tertiary">
No ads for this campaign.
</p>
)}
</div>
{/* Right: Details + Funnel + Source */}
<div className="space-y-4">
{/* Campaign Details card */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
{/* Conversion Funnel */}
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
{/* Source Breakdown */}
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</TabPanel>
<TabPanel id="leads">
<div className="mt-5 pb-7">
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
<p className="text-md font-bold text-primary">
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
</p>
<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 className="space-y-3">
{campaignAds.map((ad) => (
<AdCard key={ad.id} ad={ad} />
))}
</div>
</div>
)}
</div>
<div className="space-y-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>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
</TabPanel>
</Tabs>
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</div>
{activityLead && (
<LeadActivitySlideout
isOpen={!!activityLead}
onOpenChange={(open) => !open && setActivityLead(null)}
lead={activityLead}
activities={leadActivities}
/>
)}
</div>
);
};

View File

@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
<Button
size="sm"
color="secondary"
isDisabled
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} />
)}

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,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { PageHeader } from '@/components/layout/page-header';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { BargeControls } from '@/components/call-desk/barge-controls';
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
const [contextLoading, setContextLoading] = useState(false);
const { leads } = useData();
// Poll active calls every 5 seconds
// Initial load + SSE stream for real-time active call updates
useEffect(() => {
const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(calls => {
setActiveCalls(calls);
// If selected call ended, clear selection
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
})
.catch(() => {})
.finally(() => setLoading(false));
};
// Initial snapshot
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(setActiveCalls)
.catch(() => {})
.finally(() => setLoading(false));
fetchCalls();
const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval);
}, [selectedCall?.ucid]);
// SSE stream — receives update/remove events in real-time
const apiUrl = import.meta.env.VITE_API_URL ?? '';
const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
es.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
setActiveCalls(prev => {
if (event.type === 'remove') {
return prev.filter(c => c.ucid !== event.ucid);
}
if (event.type === 'update' && event.call) {
const exists = prev.find(c => c.ucid === event.ucid);
if (exists) {
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
}
return [...prev, event.call];
}
return prev;
});
} catch {}
};
es.onerror = () => {
// SSE reconnects automatically; no-op
};
return () => es.close();
}, []);
// Clear selection if the selected call ended
useEffect(() => {
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
}, [activeCalls, selectedCall]);
// Tick every second for duration display
useEffect(() => {
@@ -160,7 +182,11 @@ export const LiveMonitorPage = () => {
return (
<>
<TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
<PageHeader
title="Live Call Monitor"
badge={activeCalls.length}
infoText="Monitor, whisper, or barge into active calls in real-time."
/>
<div className="flex flex-1 overflow-hidden">
{/* Left panel — KPIs + call list */}

View File

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

View File

@@ -7,9 +7,8 @@ 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 { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client';
@@ -57,16 +56,109 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
};
const columnDefs = [
{ id: 'caller', label: 'Caller', defaultVisible: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
{ id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'branch', label: 'Branch', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true },
{ id: 'count', label: 'Count', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: 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 },
];
// 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, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
columnKey: string;
sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void;
}) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table key={columnKey} 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} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true);
@@ -146,144 +238,70 @@ export const MissedCallsPage = () => {
];
return (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + toolbar */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
<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="flex items-center gap-3 pb-1">
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
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={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => handleTab(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>
}
/>
{/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header>
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
{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>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
{/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>
)}
</div>
</>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
);
};

View File

@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
// useNavigate removed — row click opens profile panel
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';
const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar';
// Button removed — actions are icon-only now
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 { 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 { useData } from '@/providers/data-provider';
import { getInitials } from '@/lib/format';
@@ -55,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? '';
};
export const PatientsPage = () => {
const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
@@ -85,37 +85,36 @@ export const PatientsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="All Patients"
badge={filteredPatients.length}
infoText="Manage and view patient records"
controls={
<>
<button
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"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</>
}
/>
<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"
badge={filteredPatients.length}
description="Manage and view patient records"
contentTrailing={
<div className="flex items-center gap-2">
<button
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"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</div>
}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p>
@@ -132,10 +131,10 @@ export const PatientsPage = () => {
<Table>
<Table.Header>
<Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" />
<Table.Head label="PHONE" />
<Table.Head label="EMAIL" />
<Table.Head label="GENDER" />
<Table.Head label="AGE" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => {
@@ -149,10 +148,10 @@ export const PatientsPage = () => {
: '?';
return (
<Table.Row
<Table.Row
id={patient.id}
className={cx(
'cursor-pointer',
'cursor-pointer group/row',
selectedPatient?.id === patient.id && 'bg-brand-primary'
)}
onAction={() => {
@@ -180,18 +179,22 @@ export const PatientsPage = () => {
</div>
</Table.Cell>
{/* Contact */}
{/* Phone — clickable to dial */}
<Table.Cell>
<div className="flex flex-col">
{phone ? (
<span className="text-sm text-secondary">{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>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={phone} />
) : (
<span className="text-sm text-placeholder">No phone</span>
)}
</Table.Cell>
{/* Email */}
<Table.Cell>
{email ? (
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
) : (
<span className="text-sm text-quaternary"></span>
)}
</Table.Cell>
{/* Gender */}
@@ -208,54 +211,18 @@ export const PatientsPage = () => {
</span>
</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.Body>
</Table>
)}
</TableCard.Root>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div>
)}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div>
)}
</div>
{/* Patient Profile Panel - collapsible with smooth transition */}

View File

@@ -9,7 +9,7 @@ import {
faPalette,
faShieldHalved,
} from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { PageHeader } from '@/components/layout/page-header';
import { SectionCard } from '@/components/setup/section-card';
import {
SETUP_STEP_NAMES,
@@ -50,7 +50,7 @@ export const SettingsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Settings" subtitle="Configure your hospital workspace" />
<PageHeader title="Settings" infoText="Configure your hospital workspace." />
<div className="flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-5xl">
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
icon={faBuilding}
href="/settings/clinics"
status={STEP_TO_STATUS(state, 'clinics')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.doctors.title}
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
icon={faStethoscope}
href="/settings/doctors"
status={STEP_TO_STATUS(state, 'doctors')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.team.title}
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
icon={faUserTie}
href="/settings/team"
status={STEP_TO_STATUS(state, 'team')}
disabled
/>
</SectionGroup>
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
icon={faPhone}
href="/settings/telephony"
status={STEP_TO_STATUS(state, 'telephony')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.ai.title}
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
icon={faRobot}
href="/settings/ai"
status={STEP_TO_STATUS(state, 'ai')}
disabled
/>
<SectionCard
title="Website widget"
description="Embed the chat + booking widget on your hospital website."
icon={faGlobe}
href="/settings/widget"
disabled
/>
<SectionCard
title="Routing rules"

View File

@@ -1,15 +1,21 @@
import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PageHeader } from '@/components/layout/page-header';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { AgentTable } from '@/components/dashboard/agent-table';
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 { cx } from '@/utils/cx';
type DateRange = 'today' | 'week' | 'month';
type DashboardTab = 'agents' | 'missed' | 'campaigns';
const getDateRangeStart = (range: DateRange): Date => {
const now = new Date();
@@ -23,9 +29,13 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week');
const [tab, setTab] = useState<DashboardTab>('agents');
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 rangeStart = getDateRangeStart(dateRange);
return calls.filter((call) => {
@@ -36,112 +46,110 @@ export const TeamDashboardPage = () => {
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
const tabs = [
{ id: 'agents' as const, label: 'Agent Performance' },
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` },
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` },
];
const convRate = useMemo(() => {
if (filteredCalls.length === 0) return 0;
const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
return Math.round((completed / filteredCalls.length) * 100);
}, [filteredCalls]);
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month'] as const).map((range) => (
<button
key={range}
onClick={() => setDateRange(range)}
className={cx(
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
)}
>
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
</button>
))}
</div>
<button
onClick={() => setAiOpen(!aiOpen)}
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"
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</div>
</div>
<PageHeader
title="Team Dashboard"
subtitle={dateRangeLabel}
infoText="Aggregated call metrics, agent performance, and operational alerts."
controls={
<>
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month'] as const).map((range) => (
<button
key={range}
onClick={() => setDateRange(range)}
className={cx(
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
)}
>
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
</button>
))}
</div>
<button
onClick={() => setAiOpen(!aiOpen)}
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"
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</>
}
/>
<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">
{/* KPI cards — always visible */}
<div className="px-6 pt-5 pb-3">
<DashboardKpi calls={filteredCalls} leads={leads} />
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-secondary px-6">
{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-1 space-y-5 px-6 pb-8">
{loading && rollupAgents.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
)}
) : (
<>
<RichAgentTable agents={rollupAgents} />
{!loading && tab === 'agents' && (
<AgentTable calls={filteredCalls} />
)}
<TimeBreakdown agents={rollupAgents} />
{!loading && tab === 'missed' && (
<MissedQueue calls={filteredCalls} />
)}
<NpsConversion agents={rollupAgents} convRate={convRate} />
{!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">
<div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
<span>{c.campaignStatus}</span>
<span>{c.platform}</span>
<span>{c.leadCount} leads</span>
<span>{c.convertedCount} converted</span>
<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>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
<span>{c.campaignStatus}</span>
<span>{c.platform}</span>
<span>{c.leadCount} leads</span>
<span>{c.convertedCount} converted</span>
</div>
</div>
{c.budget && (
<span className="text-sm font-medium text-secondary">
{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
</span>
)}
</div>
</div>
{c.budget && (
<span className="text-sm font-medium text-secondary">
{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
</span>
)}
))}
</div>
))
)}
</div>
)}
</div>
</>
)}
</div>
</div>

View File

@@ -5,7 +5,7 @@ import {
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
faPercent, faTriangleExclamation,
} from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { PageHeader } from '@/components/layout/page-header';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client';
@@ -291,25 +291,28 @@ export const TeamPerformancePage = () => {
if (loading) {
return (
<>
<TopBar title="Team Performance" />
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader title="Team Dashboard" infoText="Aggregated metrics across all agents." />
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading team performance...</p>
</div>
</>
</div>
);
}
return (
<>
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="Team Dashboard"
infoText="Aggregated metrics across all agents."
controls={<DateFilter value={range} onChange={setRange} />}
/>
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Section 1: Key Metrics */}
<div className="px-6 pt-5">
<div className="flex items-center justify-between mb-4">
<div className="mb-4">
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
<DateFilter value={range} onChange={setRange} />
</div>
<div className="grid grid-cols-5 gap-3">
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
@@ -510,6 +513,6 @@ export const TeamPerformancePage = () => {
</div>
)}
</div>
</>
</div>
);
};

View File

@@ -1,15 +1,15 @@
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 {
LEADS_QUERY,
CAMPAIGNS_QUERY,
ADS_QUERY,
FOLLOW_UPS_QUERY,
LEAD_ACTIVITIES_QUERY,
CALLS_QUERY,
APPOINTMENTS_QUERY,
PATIENTS_QUERY,
leadsQuery,
campaignsQuery,
adsQuery,
followUpsQuery,
leadActivitiesQuery,
callsQuery,
appointmentsQuery,
patientsQuery,
} from '@/lib/queries';
import {
transformLeads,
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
// These don't have platform entities yet — empty for now
const [templates] = useState<WhatsAppTemplate[]>([]);
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
return;
}
setLoading(true);
// 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);
}
setError(null);
try {
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([
gql<any>(LEADS_QUERY),
gql<any>(CAMPAIGNS_QUERY),
gql<any>(ADS_QUERY),
gql<any>(FOLLOW_UPS_QUERY),
gql<any>(LEAD_ACTIVITIES_QUERY),
gql<any>(CALLS_QUERY),
gql<any>(APPOINTMENTS_QUERY),
gql<any>(PATIENTS_QUERY),
fetchAll('leads', leadsQuery),
fetchAll('campaigns', campaignsQuery),
fetchAll('ads', adsQuery),
fetchAll('followUps', followUpsQuery),
fetchAll('leadActivities', leadActivitiesQuery),
fetchAll('calls', callsQuery),
fetchAll('appointments', appointmentsQuery),
fetchAll('patients', patientsQuery),
]);
if (leadsData) setLeads(transformLeads(leadsData));
@@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
} catch (err: any) {
setError(err.message ?? 'Failed to load data');
} finally {
hasLoadedRef.current = true;
setLoading(false);
}
}, []);