28 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:34:37 +05:30
33 changed files with 2070 additions and 1028 deletions

View File

@@ -159,19 +159,33 @@ REDIS_URL=redis://localhost:6379
### Frontend ### Frontend
Each tenant has its own frontend directory on EC2. The `VITE_API_URL` is baked at build time so each build points to the correct sidecar.
```bash ```bash
# Helper — reuse in all commands below # Helper — reuse in all commands below
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194" EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no" EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
cd helix-engage && npm run build cd helix-engage
# ── Ramaiah (production pilot — deploy stable builds only) ──
VITE_API_URL=https://ramaiah.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \ rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/ dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-ramaiah/
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy" # ── Global (staging — new features land here first) ──
VITE_API_URL=https://global.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-global/
``` ```
| Tenant | Frontend Dir | API URL (baked) | Caddy Root |
|---|---|---|---|
| Ramaiah | `/opt/fortytwo/frontend-ramaiah/` | `https://ramaiah.engage.healix360.net` | `/srv/engage-ramaiah` |
| Global | `/opt/fortytwo/frontend-global/` | `https://global.engage.healix360.net` | `/srv/engage-global` |
**Important:** Always build with the correct `VITE_API_URL` for the target tenant. A build without it (or with `localhost`) will break login and API calls.
### Sidecar ### Sidecar
```bash ```bash

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

View File

@@ -22,6 +22,7 @@ import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities'; import type { Lead, CallDisposition } from '@/types/entities';
@@ -41,7 +42,8 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
const networkQuality = useNetworkStatus();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
@@ -71,7 +73,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Upcoming appointments for this caller (if returning patient) — drives // Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing // the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones. // bookings in addition to creating new ones.
const { appointments } = useData(); const { appointments, refresh } = useData();
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId; const patientId = (lead as any)?.patientId;
if (!patientId) return []; if (!patientId) return [];
@@ -103,10 +105,44 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { supervisorPresence } = useAgentState(agentIdForState); const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const isOutbound = callDirectionRef.current === 'OUTBOUND';
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
// confirmedAnswered — latched state (did a real conversation happen?)
// Inbound: set true on active (immediate). Outbound: set true after
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
// the acw→ended timing gap. Used for disposition routing AND outbound
// button gating.
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
const unansweredDisposeFired = useRef(false);
useEffect(() => {
if (!isOutbound && callState === 'active') {
setConfirmedAnswered(true);
}
}, [callState, isOutbound]);
useEffect(() => {
if (isOutbound && customerAnswered && !confirmedAnswered) {
const timer = setTimeout(() => {
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
setConfirmedAnswered(true);
}, 3000);
return () => clearTimeout(timer);
}
}, [customerAnswered, isOutbound, confirmedAnswered]);
// Button gating: inbound uses live signal, outbound uses debounced latch
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
// ── DEBUG: trace every state change ──
useEffect(() => {
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
useEffect(() => { useEffect(() => {
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
@@ -124,13 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}; };
}, [callUcid]); }, [callUcid]);
// Detect caller disconnect: call was active and ended without agent pressing End // Detect caller disconnect: call ended without agent pressing End.
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
useEffect(() => { useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
if (confirmedAnswered) {
setCallerDisconnected(true); setCallerDisconnected(true);
setDispositionOpen(true); setDispositionOpen(true);
} }
}, [callState, dispositionOpen]); }, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -180,6 +219,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
refresh();
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
if (lead?.id) {
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
}
if (outcome === 'RESCHEDULED') { if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE'); addActions('RESCHEDULE');
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
@@ -195,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setConfirmedAnswered(false);
setActionsTaken([]); setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
@@ -203,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.(); onCallComplete?.();
}; };
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
useEffect(() => {
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
unansweredDisposeFired.current = true;
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
apiClient.post('/api/ozonetel/dispose', {
ucid: callUcid,
disposition: 'NO_ANSWER',
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: 'OUTBOUND',
durationSec: 0,
leadId: lead?.id ?? null,
leadName: fullName || null,
notes: 'Auto-disposed — customer did not answer',
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
}
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
// Outbound ringing // Outbound ringing
if (callState === 'ringing-out') { if (callState === 'ringing-out') {
return ( return (
@@ -220,11 +285,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>} {fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> {/* Cancel button removed per product — risk: agent can't abort
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}> a misdialled outbound call before the customer answers.
Cancel <Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
</Button>
</div>
</div> </div>
); );
} }
@@ -248,14 +311,14 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Button size="sm" color="primary" onClick={answer}>Answer</Button> <Button size="sm" color="primary" onClick={answer}>Answer</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button> {/* Decline hidden per product — reject returns call to Ozonetel queue */}
</div> </div>
</div> </div>
); );
} }
// Unanswered call (ringing → ended without ever reaching active) if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
return ( return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center"> <div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
@@ -270,10 +333,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true;
return ( return (
<> <>
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}> <div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
{/* Network loss alert — prominent banner during active call */}
{networkQuality !== 'good' && (
<div className={cx(
'shrink-0 px-4 py-2 text-xs font-medium text-center',
networkQuality === 'offline'
? 'bg-error-solid text-white'
: 'bg-warning-secondary text-warning-primary',
)}>
{networkQuality === 'offline'
? 'Network connection lost — call may have dropped'
: 'Network unstable — call quality may be affected'}
</div>
)}
{/* Pinned: caller info + controls */} {/* Pinned: caller info + controls */}
<div className="shrink-0 p-4"> <div className="shrink-0 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -338,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}> onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'} {leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button> </Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button> onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'} <Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button> onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto" <Button size="sm" color="primary-destructive" className="ml-auto"
@@ -530,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={dispositionOpen} isOpen={dispositionOpen}
callerName={fullName || phoneDisplay} callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected} callerDisconnected={callerDisconnected}
// wasAnsweredRef only flips true once callState reaches callAnswered={confirmedAnswered}
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
actionsTaken={actionsTaken} actionsTaken={actionsTaken}
onSubmit={handleDisposition} onSubmit={handleDisposition}
onDismiss={() => { onDismiss={() => {

View File

@@ -1,9 +1,11 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useThemeTokens } from '@/providers/theme-token-provider'; import { useThemeTokens } from '@/providers/theme-token-provider';
import { useChat } from '@ai-sdk/react'; import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
import { AiSuggestions, type Suggestion } from './ai-suggestions';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
@@ -16,14 +18,10 @@ type CallerContext = {
interface AiChatPanelProps { interface AiChatPanelProps {
callerContext?: CallerContext; callerContext?: CallerContext;
callerSummary?: CallerSummary | null;
onChatStart?: () => void; onChatStart?: () => void;
} }
// Supervisor has different quick-action prompts than the CC agent — they
// ask about team metrics, not patient / doctor info. Hardcoded here rather
// than in theme tokens because the prompts map 1:1 to the supervisor tool
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
// get_campaign_stats) — changing the tools means changing these prompts.
const SUPERVISOR_QUICK_ACTIONS = [ const SUPERVISOR_QUICK_ACTIONS = [
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' }, { label: 'Agent performance', prompt: 'Show me agent performance this week.' },
{ label: 'Call summary', prompt: 'Summarize call activity this week.' }, { label: 'Call summary', prompt: 'Summarize call activity this week.' },
@@ -33,27 +31,49 @@ const SUPERVISOR_QUICK_ACTIONS = [
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.'; const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => {
const trimmed = content.trim();
try {
const parsed = JSON.parse(trimmed);
if (parsed.message) {
return {
message: parsed.message,
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
};
}
} catch {}
return { message: content, suggestions: [] };
};
export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens(); const { tokens } = useThemeTokens();
const isSupervisor = callerContext?.type === 'supervisor'; const isSupervisor = callerContext?.type === 'supervisor';
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions; const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.'; const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatStartedRef = useRef(false); const chatStartedRef = useRef(false);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({ const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
api: `${API_URL}/api/ai/stream`, api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text', streamProtocol: 'text',
headers: { headers: { 'Authorization': `Bearer ${token}` },
'Authorization': `Bearer ${token}`, body: { context: callerContext },
},
body: {
context: callerContext,
},
}); });
useEffect(() => {
if (isLoading) return;
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAssistant) {
const parsed = parseAiResponse(lastAssistant.content);
if (parsed.suggestions.length > 0) {
setSuggestions(parsed.suggestions);
}
}
}, [messages, isLoading]);
useEffect(() => { useEffect(() => {
const el = messagesEndRef.current; const el = messagesEndRef.current;
if (el?.parentElement) { if (el?.parentElement) {
@@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
} }
}, [messages, onChatStart]); }, [messages, onChatStart]);
// Auto-fire a patient-summary request when a caller with a leadId appears
// on the panel. Resets whenever the caller changes (new incoming call) or
// the call ends (leadId clears), so each call starts fresh. The sidecar's
// AI agent inspects the leadId and replies with appointment/disposition/
// notes history when the caller is a returning patient.
const autoFiredForLeadRef = useRef<string | null>(null); const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const leadId = callerContext?.leadId ?? null; const leadId = callerContext?.leadId ?? null;
// Call ended or no caller — wipe the panel so the next caller's
// context doesn't bleed over and the agent isn't staring at a stale
// summary in the worklist view between calls.
if (!leadId) { if (!leadId) {
if (autoFiredForLeadRef.current !== null) { if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null; autoFiredForLeadRef.current = null;
setMessages([]); setMessages([]);
setSuggestions([]);
chatStartedRef.current = false; chatStartedRef.current = false;
} }
return; return;
} }
if (autoFiredForLeadRef.current === leadId) return; if (autoFiredForLeadRef.current === leadId) return;
// New caller — clear any prior chat state and fire the summary prompt.
autoFiredForLeadRef.current = leadId; autoFiredForLeadRef.current = leadId;
setMessages([]); setMessages([]);
setSuggestions([]);
chatStartedRef.current = false; chatStartedRef.current = false;
const name = callerContext?.leadName ?? 'this caller'; const name = callerContext?.leadName ?? 'this caller';
append({ append({
role: 'user', 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]); }, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
@@ -103,15 +113,37 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
append({ role: 'user', content: prompt }); append({ role: 'user', content: prompt });
}; };
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
append({
role: 'user',
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
});
}, [append]);
// Filter out the currently-streaming assistant message (shows raw JSON).
// Only display completed assistant messages with parsed content.
const displayMessages = messages
.filter((msg, i) => {
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
return true;
})
.map(msg => {
if (msg.role === 'assistant') {
const parsed = parseAiResponse(msg.content);
return { ...msg, content: parsed.message };
}
return msg;
});
return ( return (
<div className="flex h-full flex-col p-3"> <div className="flex h-full flex-col gap-2 p-3">
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
<div className="flex-1 space-y-3 overflow-y-auto min-h-0"> <div className="flex-1 space-y-3 overflow-y-auto min-h-0">
{messages.length === 0 && ( {displayMessages.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-xs text-tertiary"> <p className="text-xs text-tertiary">{introText}</p>
{introText}
</p>
<div className="mt-3 flex flex-wrap justify-center gap-1.5"> <div className="mt-3 flex flex-wrap justify-center gap-1.5">
{quickActions.map((action) => ( {quickActions.map((action) => (
<button <button
@@ -127,18 +159,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
</div> </div>
)} )}
{messages.map((msg) => ( {displayMessages.map((msg) => (
<div <div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
key={msg.id} <div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`} msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
> }`}>
<div
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
msg.role === 'user'
? 'bg-brand-solid text-white'
: 'bg-secondary text-primary'
}`}
>
{msg.role === 'assistant' && ( {msg.role === 'assistant' && (
<div className="mb-1 flex items-center gap-1"> <div className="mb-1 flex items-center gap-1">
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
@@ -165,7 +190,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0"> {!isSupervisor && suggestions.length > 0 && (
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
)}
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100"> <div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" /> <FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
<input <input
@@ -188,20 +217,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
</div> </div>
); );
}; };
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
const parseLine = (text: string): ReactNode[] => { const parseLine = (text: string): ReactNode[] => {
const parts: ReactNode[] = []; const parts: ReactNode[] = [];
const boldPattern = /\*\*(.+?)\*\*/g; const boldPattern = /\*\*(.+?)\*\*/g;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = boldPattern.exec(text)) !== null) { while ((match = boldPattern.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>); parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
lastIndex = boldPattern.lastIndex; lastIndex = boldPattern.lastIndex;
} }
if (lastIndex < text.length) parts.push(text.slice(lastIndex)); if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? parts : [text]; return parts.length > 0 ? parts : [text];
}; };
@@ -209,7 +235,6 @@ const parseLine = (text: string): ReactNode[] => {
const MessageContent = ({ content }: { content: string }) => { const MessageContent = ({ content }: { content: string }) => {
if (!content) return null; if (!content) return null;
const lines = content.split('\n'); const lines = content.split('\n');
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{lines.map((line, i) => { {lines.map((line, i) => {

View File

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

View File

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

View File

@@ -1,28 +1,13 @@
import { useState, useCallback, useMemo } from 'react'; 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 { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges'; import type { Appointment } from '@/types/entities';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
// The context panel can render for any worklist item — not just leads.
// Missed calls and follow-ups provide a subset of the fields (phone +
// patientId + name) without a full Lead entity. ContextPanelSubject
// captures the minimum the panel needs to render P360.
export type ContextPanelSubject = { export type ContextPanelSubject = {
id: string; id: string;
contactName?: { firstName: string; lastName: string } | null; contactName?: { firstName: string; lastName: string } | null;
contactPhone?: Array<{ number: string; callingCode: string }> | null; contactPhone?: Array<{ number: string; callingCode: string }> | null;
patientId?: string | null; patientId?: string | null;
// Lead-specific fields — present when the subject IS a lead
leadSource?: string | null; leadSource?: string | null;
leadStatus?: string | null; leadStatus?: string | null;
aiSummary?: string | null; aiSummary?: string | null;
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
interface ContextPanelProps { interface ContextPanelProps {
selectedLead: ContextPanelSubject | null; selectedLead: ContextPanelSubject | null;
activities: LeadActivity[]; activities: any[];
calls: Call[]; calls: any[];
followUps: FollowUp[]; followUps: any[];
appointments: Appointment[]; appointments: Appointment[];
patients: Patient[]; patients: any[];
callerPhone?: string; callerPhone?: string;
isInCall?: boolean; isInCall?: boolean;
callUcid?: string | null; callUcid?: string | null;
} }
const formatTimeAgo = (dateStr: string): string => { export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const formatDuration = (sec: number): string => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
}) => (
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
>
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
{count !== undefined && count > 0 && (
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
)}
<FontAwesomeIcon
icon={expanded ? faChevronUp : faChevronDown}
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
/>
</button>
);
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const navigate = useNavigate();
const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true);
const [recentExpanded, setRecentExpanded] = useState(true);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null); const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const lead = selectedLead; const lead = selectedLead;
@@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
leadName: fullName, leadName: fullName,
} : callerPhone ? { callerPhone } : undefined; } : callerPhone ? { callerPhone } : undefined;
// Filter data for this lead
const leadCalls = useMemo(() =>
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[calls, lead, callerPhone],
);
const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3),
[followUps, lead],
);
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = lead?.patientId; const patientId = lead?.patientId;
if (!patientId) return []; if (!patientId) return [];
@@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
.slice(0, 3); .slice(0, 3);
}, [appointments, lead]); }, [appointments, lead]);
const leadActivities = useMemo(() => const handleChatStart = useCallback(() => {}, []);
activities.filter(a => a.leadId === lead?.id)
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
.slice(0, 5),
[activities, lead],
);
// Linked patient // Edit mode takes over the whole right panel
const linkedPatient = useMemo(() =>
patients.find(p => p.id === lead?.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.
if (editingAppointment) { if (editingAppointment) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -178,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 ( return (
<div className="flex h-full flex-col"> <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"> <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>
</div> </div>
); );

View File

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

View File

@@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components'; import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell'; import { PhoneActionCell } from './phone-action-cell';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format'; import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -79,6 +75,9 @@ interface WorklistPanelProps {
onSelectItem: (selection: WorklistSelection) => void; onSelectItem: (selection: WorklistSelection) => void;
selectedItemId: string | null; selectedItemId: string | null;
onDialMissedCall?: (missedCallId: string) => void; onDialMissedCall?: (missedCallId: string) => void;
// Lifted from internal state — owned by call-desk.tsx so the search
// input can live in the PageHeader row alongside other controls.
search: string;
} }
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
@@ -178,34 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
const formatDisposition = (disposition: string): string => const formatDisposition = (disposition: string): string =>
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatSource = (source: string): string => { // formatSource + formatDid kept for reference but no longer rendered
const map: Record<string, string> = { // in the table — SOURCE/BRANCH column removed from display per user
FACEBOOK_AD: 'Facebook', // request. Data stays on the row for future use.
GOOGLE_AD: 'Google',
WALK_IN: 'Walk-in',
REFERRAL: 'Referral',
WEBSITE: 'Website',
PHONE_INQUIRY: 'Phone',
PHONE: 'Phone',
OTHER: 'Other',
};
return map[source] ?? source.replace(/_/g, ' ');
};
// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name.
// The DID is the phone number the caller dialed — it identifies which
// hospital branch or campaign the call came through. Falls back to the
// last 10 digits if no mapping is found.
const formatDid = (did: string): string => {
if (!did) return '—';
// Known DIDs — loaded from the sidecar theme tokens at boot (via
// the ThemeTokenProvider). For now, hardcode nothing — strip country
// code and show the DID as a short number so it's at least readable.
const digits = did.replace(/\D/g, '');
// Strip leading 91 (India) for display
const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits;
return short;
};
const IconInbound = faIcon(faPhoneArrowDown); const IconInbound = faIcon(faPhoneArrowDown);
const IconOutbound = faIcon(faPhoneArrowUp); const IconOutbound = faIcon(faPhoneArrowUp);
@@ -239,7 +213,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
// Branch column: prefer the campaign name (e.g. "Cervical Cancer // Branch column: prefer the campaign name (e.g. "Cervical Cancer
// Screening Drive") over the raw DID. Falls back to formatted DID // Screening Drive") over the raw DID. Falls back to formatted DID
// for organic calls with no campaign. // for organic calls with no campaign.
source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null), source: call.campaign?.campaignName ?? call.callSourceNumber ?? null,
lastDisposition: call.disposition ?? null, lastDisposition: call.disposition ?? null,
missedCallId: call.id, missedCallId: call.id,
}); });
@@ -324,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
return actionableRows; return actionableRows;
}; };
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
// sub-tabs were removed per QA feedback — pending callbacks are the only // sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist. // ones agents need to act on from the worklist.
@@ -427,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Reset page when search changes from parent
useEffect(() => { setPage(1); }, [search]);
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -461,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */} {/* Filter pills — custom buttons matching All Leads pattern */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5"> <div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}> {tabItems.map((item) => (
<TabList items={tabItems} type="underline" size="sm"> <button
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} key={item.id}
</TabList> onClick={() => handleTabChange(item.id)}
</Tabs> className={cx(
<div className="w-44 shrink-0"> 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
<Input tab === item.id
placeholder="Search..." ? 'bg-brand-solid text-white'
icon={SearchLg} : 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
size="sm" )}
value={search} >
onChange={handleSearch} {item.label}{item.badge ? ` (${item.badge})` : ''}
aria-label="Search worklist" </button>
/> ))}
</div>
</div> </div>
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab {/* Missed-call sub-tabs removed per QA feedback — the Missed tab
@@ -497,7 +471,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting /> <Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
<Table.Head id="name" label="PATIENT" allowsSorting /> <Table.Head id="name" label="PATIENT" allowsSorting />
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting /> <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
@@ -578,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<span className="text-xs text-quaternary italic">No phone</span> <span className="text-xs text-quaternary italic">No phone</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell>
{row.source ? (
<span className="text-xs text-tertiary truncate block max-w-[100px]">
{formatSource(row.source)}
</span>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color"> <Badge size="sm" color={sla.color} type="pill-color">
{sla.label} {sla.label}

View File

@@ -8,7 +8,6 @@ import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget'; import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner'; import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -16,6 +15,7 @@ import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status'; import { useNetworkStatus } from '@/hooks/use-network-status';
// import { GlobalSearch } from '@/components/shared/global-search'; // import { GlobalSearch } from '@/components/shared/global-search';
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -118,35 +118,25 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex h-screen bg-primary"> <div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} /> <Sidebar activeUrl={pathname} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */} {/* Agent top bar — network indicator + status toggle (agents only) */}
{(hasAgentConfig || isAdmin) && ( {hasAgentConfig && (
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
{/* GlobalSearch hidden — navigation on result click
routes to Patient 360 with stale appointment state
from the call desk. Revisit when the Patient 360
route properly resets context on mount. (#4) */}
{/* <GlobalSearch /> */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />} <div className={cx(
{hasAgentConfig && ( 'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
<> networkQuality === 'good'
<div className={cx( ? 'bg-success-primary text-success-primary'
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium', : networkQuality === 'offline'
networkQuality === 'good' ? 'bg-error-secondary text-error-primary'
? 'bg-success-primary text-success-primary' : 'bg-warning-secondary text-warning-primary',
: networkQuality === 'offline' )}>
? 'bg-error-secondary text-error-primary' <FontAwesomeIcon
: 'bg-warning-secondary text-warning-primary', icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
)}> className="size-3"
<FontAwesomeIcon />
icon={networkQuality === 'offline' ? faWifiSlash : faWifi} {networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
className="size-3" </div>
/> <AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
</div> </div>
</div> </div>
)} )}
@@ -154,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div> </div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />} {isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
{isAdmin && !isCCAgent && <AiFloatingButton />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -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

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

View File

@@ -17,6 +17,7 @@ type SectionCardProps = {
href?: string; href?: string;
onClick?: () => void; onClick?: () => void;
status?: SectionStatus; status?: SectionStatus;
disabled?: boolean;
}; };
// Settings hub card. Each card represents one setup-able section (Branding, // Settings hub card. Each card represents one setup-able section (Branding,
@@ -30,26 +31,32 @@ export const SectionCard = ({
href, href,
onClick, onClick,
status = 'unknown', status = 'unknown',
disabled = false,
}: SectionCardProps) => { }: SectionCardProps) => {
const className = cx( const className = cx(
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md', '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 = ( const body = (
<> <>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary"> <div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} /> <FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
</div> </div>
<div className="min-w-0"> <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> <p className="mt-1 text-xs text-tertiary">{description}</p>
</div> </div>
</div> </div>
<FontAwesomeIcon {!disabled && (
icon={faArrowRight} <FontAwesomeIcon
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary" icon={faArrowRight}
/> className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
)}
</div> </div>
{status !== 'unknown' && ( {status !== 'unknown' && (
@@ -70,6 +77,13 @@ export const SectionCard = ({
</> </>
); );
if (disabled) {
return (
<div className={className}>
{body}
</div>
);
}
if (onClick) { if (onClick) {
return ( return (
<button type="button" onClick={onClick} className={className}> <button type="button" onClick={onClick} className={className}>

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { notify } from './toast'; import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; // In production, use the current origin — Caddy routes /api/* to the
// correct per-tenant sidecar based on hostname. Only use VITE_API_URL
// for local dev (pointing to a specific sidecar).
const API_URL = import.meta.env.VITE_API_URL || window.location.origin;
class AuthError extends Error { class AuthError extends Error {
constructor(message = 'Authentication required') { constructor(message = 'Authentication required') {

View File

@@ -49,7 +49,8 @@ import { IntegrationsPage } from "@/pages/integrations";
import { AgentDetailPage } from "@/pages/agent-detail"; import { AgentDetailPage } from "@/pages/agent-detail";
import { SettingsPage } from "@/pages/settings"; import { SettingsPage } from "@/pages/settings";
import { MyPerformancePage } from "@/pages/my-performance"; import { MyPerformancePage } from "@/pages/my-performance";
import { AppointmentsPage } from "@/pages/appointments"; // v2 appointments — testing locally via Tauri before replacing v1
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
import { TeamPerformancePage } from "@/pages/team-performance"; import { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor"; import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings"; import { CallRecordingsPage } from "@/pages/call-recordings";

View File

@@ -11,8 +11,7 @@ import { Input } from '@/components/base/input/input';
// Tabs removed — campaign pills handle all filtering now // Tabs removed — campaign pills handle all filtering now
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; // import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header import { PageHeader } from '@/components/layout/page-header';
// import { TopBar } from '@/components/layout/top-bar';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
// Bulk actions removed — checkboxes hidden // Bulk actions removed — checkboxes hidden
@@ -147,9 +146,7 @@ export const AllLeadsPage = () => {
result = result.filter((l) => l.assignedAgent === user.name); result = result.filter((l) => l.assignedAgent === user.name);
} }
if (campaignFilter) { if (campaignFilter) {
result = campaignFilter === '__none__' result = result.filter((l) => l.campaignId === campaignFilter);
? result.filter((l) => !l.campaignId)
: result.filter((l) => l.campaignId === campaignFilter);
} }
return result; return result;
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]); }, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
@@ -244,37 +241,37 @@ export const AllLeadsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header with controls inline */} <PageHeader
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> title="All Leads"
<div> subtitle={`${total} total`}
<h1 className="text-lg font-bold text-primary">All Leads</h1> infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
<p className="text-xs text-tertiary">{total} total</p> controls={
</div> <>
<div className="flex items-center gap-2"> <div className="w-56">
<div className="w-56"> <Input
<Input placeholder="Search leads..."
placeholder="Search leads..." icon={SearchLg}
icon={SearchLg} size="sm"
value={searchQuery}
onChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
aria-label="Search leads"
/>
</div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button
size="sm" size="sm"
value={searchQuery} color="secondary"
onChange={(value) => { iconLeading={Download01}
setSearchQuery(value); onClick={handleExportCsv}
setCurrentPage(1); >
}} Export CSV
aria-label="Search leads" </Button>
/> </>
</div> }
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> />
<Button
size="sm"
color="secondary"
iconLeading={Download01}
onClick={handleExportCsv}
>
Export CSV
</Button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
@@ -321,17 +318,6 @@ export const AllLeadsPage = () => {
</button> </button>
); );
})} })}
<button
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
campaignFilter === '__none__'
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
</button>
</div> </div>
)} )}

View File

@@ -0,0 +1,604 @@
// Appointments v2 — lean table + detail side panel + reschedule
// Uses DataProvider as single source of truth for appointment data.
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMagnifyingGlass, faPenToSquare, 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';
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 { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import type { Appointment } from '@/types/entities';
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 getPatientName = (appt: Appointment): string =>
appt.patientName || 'Unknown';
const getPhone = (appt: Appointment): string =>
appt.patientPhone ?? '';
const canEdit = (appt: Appointment): boolean =>
appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
// ── 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: Appointment;
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.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
</Badge>
</div>
<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.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>
<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.
</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 ─────────────────────────────────────────────
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: Appointment;
onClose: () => void;
onSaved: () => void;
}) => {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [department, setDepartment] = useState(appointment.department ?? '');
const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
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);
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(() => {});
}, []);
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]);
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">
<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>
<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>
<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>
{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>
)}
<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>
<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>
<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, loading, refresh } = useData();
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20;
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of appointments) {
const s = a.appointmentStatus ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [appointments]);
const filtered = useMemo(() => {
let rows = [...appointments].sort((a, b) => {
const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
return db - da;
});
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === 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: Appointment) => {
setSelectedAppt(appt);
setPanelOpen(true);
setRescheduleOpen(false);
};
const handleRescheduleSaved = () => {
setRescheduleOpen(false);
setPanelOpen(false);
setSelectedAppt(null);
refresh();
notify.success('Appointment Rescheduled');
};
return (
<>
<PageHeader
title="Appointments"
badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row 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="PATIENT" className="min-w-[180px]" isRowHeader />
<Table.Head label="DATE & TIME" className="w-28" />
<Table.Head label="DOCTOR" className="min-w-[160px]" />
<Table.Head label="STATUS" className="w-24" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
const name = getPatientName(appt);
const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
const isSelected = selectedAppt?.id === appt.id;
return (
<Table.Row
id={appt.id}
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
onAction={() => handleEditClick(appt)}
>
<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>
<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>
<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>
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</div>
<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 { useState, useEffect, useRef, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -12,11 +12,15 @@ import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel'; import { ContextPanel } from '@/components/call-desk/context-panel';
import type { ContextPanelSubject } from '@/components/call-desk/context-panel'; import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Input } from '@/components/base/input/input';
import { faIcon } from '@/lib/icon-wrapper';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const SearchLg = faIcon(faMagnifyingGlass);
export const CallDeskPage = () => { export const CallDeskPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
@@ -30,6 +34,7 @@ export const CallDeskPage = () => {
const [diallerOpen, setDiallerOpen] = useState(false); const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState(''); const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false); const [dialling, setDialling] = useState(false);
const [search, setSearch] = useState('');
// DEV: simulate incoming call // DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom); const setSimCallState = useSetAtom(sipCallStateAtom);
@@ -166,14 +171,29 @@ export const CallDeskPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */} {/* Header — matches PageHeader visual pattern */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> <div className="flex shrink-0 items-center justify-between px-6 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">Call Desk</h1> <h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span> <span className="text-sm text-tertiary ml-1">{user.name}</span>
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isInCall && (
<div className="w-52">
<Input
placeholder="Search worklist..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search worklist"
/>
</div>
)}
{import.meta.env.DEV && (!isInCall ? ( {import.meta.env.DEV && (!isInCall ? (
<button <button
onClick={startSimCall} onClick={startSimCall}
@@ -283,6 +303,7 @@ export const CallDeskPage = () => {
onSelectItem={handleSelectItem} onSelectItem={handleSelectItem}
selectedItemId={selectedItemId} selectedItemId={selectedItemId}
onDialMissedCall={(id) => setActiveMissedCallId(id)} onDialMissedCall={(id) => setActiveMissedCallId(id)}
search={search}
/> />
)} )}
</div> </div>

View File

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

View File

@@ -9,7 +9,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis'; import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
@@ -26,6 +26,7 @@ type RecordingRecord = {
callStatus: string | null; callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null; callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null; agentName: string | null;
agent: { id: string; name: string; ozonetelAgentId: string } | null;
startedAt: string | null; startedAt: string | null;
durationSec: number | null; durationSec: number | null;
disposition: string | null; disposition: string | null;
@@ -35,7 +36,8 @@ type RecordingRecord = {
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id createdAt direction callStatus callerNumber { primaryPhoneNumber } id createdAt direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition sla agentName agent { id name ozonetelAgentId }
startedAt durationSec disposition sla
recording { primaryLinkUrl primaryLinkLabel } recording { primaryLinkUrl primaryLinkLabel }
} } } }`; } } } }`;
@@ -109,7 +111,7 @@ export const CallRecordingsPage = () => {
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand'; const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
switch (colId) { switch (colId) {
case 'agent': case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>; return <span className="text-sm text-primary">{call.agent?.name ?? call.agentName ?? '—'}</span>;
case 'caller': case 'caller':
return phone return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} /> ? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
@@ -207,7 +209,7 @@ export const CallRecordingsPage = () => {
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
result = result.filter(c => result = result.filter(c =>
(c.agentName ?? '').toLowerCase().includes(q) || (c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.disposition ?? '').toLowerCase().includes(q), (c.disposition ?? '').toLowerCase().includes(q),
); );
@@ -217,7 +219,7 @@ export const CallRecordingsPage = () => {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1; const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
result = [...result].sort((a, b) => { result = [...result].sort((a, b) => {
switch (sortDescriptor.column) { 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': { case 'dateTime': {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0; const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0; const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
@@ -238,19 +240,20 @@ export const CallRecordingsPage = () => {
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); }; const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Call Recordings" /> <PageHeader
<div className="flex flex-1 flex-col overflow-hidden"> title="Call Recordings"
{/* Toolbar */} badge={filtered.length}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> infoText="All call recordings with AI analysis, dispositions, and playback."
<span className="text-sm text-tertiary">{filtered.length} recordings</span> controls={
<div className="flex items-center gap-3"> <>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56"> <div className="w-56">
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> <Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div> </div>
</div> </>
</div> }
/>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
@@ -264,7 +267,7 @@ export const CallRecordingsPage = () => {
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}> <Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header columns={activeColumns}> <Table.Header columns={activeColumns}>
{(col) => ( {(col) => (
<Table.Head <Table.Head
@@ -278,7 +281,7 @@ export const CallRecordingsPage = () => {
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(call) => ( {(call) => (
<Table.Row id={call.id} columns={activeColumns}> <Table.Row id={call.id} columns={activeColumns} className="group/row">
{(col) => ( {(col) => (
<Table.Cell key={col.id}> <Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)} {renderRecordingCell(call, col.id)}
@@ -322,7 +325,6 @@ export const CallRecordingsPage = () => {
/> />
); );
})()} })()}
</div> </div>
</>
); );
}; };

View File

@@ -73,107 +73,77 @@ export const CampaignDetailPage = () => {
<KpiStrip campaign={campaign} /> <KpiStrip campaign={campaign} />
{/* Main body: leads table on the left, campaign details + funnel + source on the right */} {/* Campaign details + funnel + source — horizontal cards above table */}
<div className="px-7 pt-5 pb-7"> <div className="px-7 pt-5">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3 mb-6">
<div className="space-y-6"> <div className="rounded-xl border border-secondary bg-primary p-4">
<div> <h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<div className="mb-3 flex items-center justify-between"> <dl className="space-y-1.5 text-xs">
<h3 className="text-md font-bold text-primary"> {[
Leads ({campaignLeads.length}) ['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
</h3> ['Platform', campaign.platform ?? '--'],
</div> ['Start', formatDateShort(campaign.startDate)],
{campaignLeads.length === 0 ? ( ['End', formatDateShort(campaign.endDate)],
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary"> ['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
No leads from this campaign yet. ['Impressions', campaign.impressionCount?.toLocaleString('en-IN') ?? '--'],
['Clicks', campaign.clickCount?.toLocaleString('en-IN') ?? '--'],
].map(([label, value]) => (
<div key={label} className="flex justify-between">
<dt className="text-quaternary">{label}</dt>
<dd className="font-medium text-secondary">{value}</dd>
</div> </div>
) : ( ))}
<LeadTable </dl>
leads={sortedLeads} <div className="mt-3 space-y-2">
selectedIds={selectedIds} <BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
onSelectionChange={setSelectedIds} <HealthIndicator campaign={campaign} leads={campaignLeads} />
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
/>
)}
</div> </div>
</div>
{campaignAds.length > 0 && ( <ConversionFunnel campaign={campaign} leads={campaignLeads} />
<div> <SourceBreakdown leads={campaignLeads} />
<h3 className="mb-3 text-md font-bold text-primary"> </div>
Ads ({campaignAds.length}) </div>
</h3>
<div className="space-y-3"> {/* Leads table — full width */}
{campaignAds.map((ad) => ( <div className="px-7 pb-7">
<AdCard key={ad.id} ad={ad} /> <div className="space-y-6">
))} <div>
</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> </div>
) : (
<LeadTable
leads={sortedLeads}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
/>
)} )}
</div> </div>
<div className="space-y-4"> {campaignAds.length > 0 && (
<div className="rounded-xl border border-secondary bg-primary p-4"> <div>
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4> <h3 className="mb-3 text-md font-bold text-primary">
<dl className="space-y-2 text-xs"> Ads ({campaignAds.length})
<div className="flex justify-between"> </h3>
<dt className="text-quaternary">Type</dt> <div className="space-y-3">
<dd className="font-medium text-secondary"> {campaignAds.map((ad) => (
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'} <AdCard key={ad.id} ad={ad} />
</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>
</div> </div>
)}
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div> </div>
</div> </div>

View File

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

View File

@@ -17,7 +17,7 @@ const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout'; import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
@@ -113,14 +113,12 @@ export const ContactsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} /> <PageHeader
title="Contacts"
<div className="flex flex-1 flex-col overflow-hidden"> badge={contacts.length}
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary"> infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
<p className="text-xs text-tertiary"> controls={
People who reached out directly phone, walk-in, referral. Not sourced from campaigns. <>
</p>
<div className="flex items-center gap-2">
<div className="w-56"> <div className="w-56">
<Input <Input
placeholder="Search contacts..." placeholder="Search contacts..."
@@ -135,9 +133,11 @@ export const ContactsPage = () => {
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}> <Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
Export CSV Export CSV
</Button> </Button>
</div> </>
</div> }
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 pt-3"> <div className="flex-1 overflow-y-auto px-4 pt-3">
<LeadTable <LeadTable
leads={paged} leads={paged}

View File

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

View File

@@ -7,9 +7,8 @@ const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -125,14 +124,15 @@ const renderCell = (call: MissedCallRecord, colId: string) => {
} }
}; };
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: { const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[]; calls: MissedCallRecord[];
columns: ColDef[]; columns: ColDef[];
columnKey: string;
sortDescriptor: SortDescriptor; sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void; onSortChange: (desc: SortDescriptor) => void;
}) => ( }) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}> <Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}> <Table.Header columns={columns}>
{(col) => ( {(col) => (
<Table.Head <Table.Head
@@ -146,7 +146,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
</Table.Header> </Table.Header>
<Table.Body items={calls}> <Table.Body items={calls}>
{(call) => ( {(call) => (
<Table.Row id={call.id} columns={columns}> <Table.Row id={call.id} columns={columns} className="group/row">
{(col) => ( {(col) => (
<Table.Cell key={col.id}> <Table.Cell key={col.id}>
{renderCell(call, col.id)} {renderCell(call, col.id)}
@@ -238,55 +238,70 @@ export const MissedCallsPage = () => {
]; ];
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Missed Calls" /> <PageHeader
<div className="flex flex-1 flex-col overflow-hidden"> title="Missed Calls"
{/* Tabs + toolbar */} badge={calls.length}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5"> infoText="Inbound calls that were not answered. Agents can call back from here."
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> controls={
<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">
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56"> <div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> <Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div> </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>
</div> }
/>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p> <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))}
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>
) : 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> </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 { useMemo, useState } from 'react';
import { useNavigate } from 'react-router'; // useNavigate removed — row click opens profile panel
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
// Button removed — actions are icon-only now
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PageHeader } from '@/components/layout/page-header';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { getInitials } from '@/lib/format'; import { getInitials } from '@/lib/format';
@@ -55,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? ''; return patient.emails?.primaryEmail ?? '';
}; };
export const PatientsPage = () => { export const PatientsPage = () => {
const { patients, loading } = useData(); const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null); const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
@@ -85,37 +85,36 @@ export const PatientsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <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 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
<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>
}
/>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p> <p className="text-sm text-tertiary">Loading patients...</p>
@@ -132,10 +131,8 @@ export const PatientsPage = () => {
<Table> <Table>
<Table.Header> <Table.Header>
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" /> <Table.Head label="PHONE" />
<Table.Head label="GENDER" /> <Table.Head label="EMAIL" />
<Table.Head label="AGE" />
<Table.Head label="ACTIONS" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
@@ -149,10 +146,10 @@ export const PatientsPage = () => {
: '?'; : '?';
return ( return (
<Table.Row <Table.Row
id={patient.id} id={patient.id}
className={cx( className={cx(
'cursor-pointer', 'cursor-pointer group/row',
selectedPatient?.id === patient.id && 'bg-brand-primary' selectedPatient?.id === patient.id && 'bg-brand-primary'
)} )}
onAction={() => { onAction={() => {
@@ -180,82 +177,37 @@ export const PatientsPage = () => {
</div> </div>
</Table.Cell> </Table.Cell>
{/* Contact */} {/* Phone — clickable to dial */}
<Table.Cell> <Table.Cell>
<div className="flex flex-col"> {phone ? (
{phone ? ( <PhoneActionCell phoneNumber={phone} displayNumber={phone} />
<span className="text-sm text-secondary">{phone}</span> ) : (
) : ( <span className="text-sm text-placeholder">No phone</span>
<span className="text-sm text-placeholder">No phone</span> )}
)}
{email ? (
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
) : null}
</div>
</Table.Cell> </Table.Cell>
{/* Gender */} {/* Email */}
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary"> {email ? (
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'} <span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
</span> ) : (
<span className="text-sm text-quaternary"></span>
)}
</Table.Cell> </Table.Cell>
{/* Age */}
<Table.Cell>
<span className="text-sm text-secondary">
{age !== null ? `${age} yrs` : '—'}
</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.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
</TableCard.Root>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3"> <div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} /> <PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div> </div>
)} )}
</div> </div>
{/* Patient Profile Panel - collapsible with smooth transition */} {/* Patient Profile Panel - collapsible with smooth transition */}

View File

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

View File

@@ -1,7 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { PageHeader } from '@/components/layout/page-header';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import { import {
@@ -28,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts) // Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's // from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
@@ -55,13 +52,11 @@ export const TeamDashboardPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header */} <PageHeader
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> title="Team Dashboard"
<div className="flex items-center gap-3"> subtitle={dateRangeLabel}
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1> infoText="Aggregated call metrics, agent performance, and operational alerts."
<span className="text-sm text-tertiary">{dateRangeLabel}</span> controls={
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-secondary overflow-hidden"> <div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month'] as const).map((range) => ( {(['today', 'week', 'month'] as const).map((range) => (
<button <button
@@ -76,15 +71,8 @@ export const TeamDashboardPage = () => {
</button> </button>
))} ))}
</div> </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>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main content — scrollable column with KPIs pinned at the {/* Main content — scrollable column with KPIs pinned at the
@@ -153,17 +141,6 @@ export const TeamDashboardPage = () => {
</div> </div>
</div> </div>
{/* AI panel — collapsible */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
aiOpen ? "w-[380px]" : "w-0 border-l-0",
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
)}
</div>
</div> </div>
</div> </div>

View File

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