20 Commits

Author SHA1 Message Date
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
23 changed files with 971 additions and 769 deletions

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

@@ -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,7 +105,11 @@ 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);
// For outbound calls, SIP goes 'active' when the agent's bridge connects
// (before customer answers). Ozonetel state stays 'calling' until customer
// picks up, then transitions to 'in-call'. Use this to gate action buttons.
const customerAnswered = callState === 'active' && ozonetelState !== 'calling';
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const wasAnsweredRef = useRef(callState === 'active');
@@ -180,6 +186,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');
@@ -220,11 +231,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,7 +257,7 @@ 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>
); );
@@ -270,10 +279,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true; if (customerAnswered) 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 +361,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={!customerAnswered}
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={!customerAnswered}
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={!customerAnswered}
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"

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

@@ -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 && <AiFloatingButton />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -20,6 +20,8 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'; import { useState, useRef, useEffect, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons'; import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { NotificationBell } from './notification-bell';
import { useAuth } from '@/providers/auth-provider';
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string;
@@ -65,7 +67,9 @@ const InfoTooltip = ({ text }: { text: string }) => {
); );
}; };
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => ( export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
const { isAdmin } = useAuth();
return (
<div className="shrink-0"> <div className="shrink-0">
{/* Row 1: title + controls */} {/* Row 1: title + controls */}
<div className="flex items-center justify-between px-6 py-3"> <div className="flex items-center justify-between px-6 py-3">
@@ -81,11 +85,10 @@ export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }:
)} )}
{infoText && <InfoTooltip text={infoText} />} {infoText && <InfoTooltip text={infoText} />}
</div> </div>
{controls && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> {controls}
{controls} {isAdmin && <NotificationBell />}
</div> </div>
)}
</div> </div>
{/* Row 2: optional tabs — no container border, tab underline is the separator */} {/* Row 2: optional tabs — no container border, tab underline is the separator */}
@@ -95,4 +98,5 @@ export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }:
</div> </div>
)} )}
</div> </div>
); );
};

View File

@@ -1,8 +1,6 @@
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 { faEye } from '@fortawesome/pro-duotone-svg-icons';
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 { LeadStatusBadge } from '@/components/shared/status-badge'; import { LeadStatusBadge } from '@/components/shared/status-badge';
@@ -94,7 +92,6 @@ export const LeadTable = ({
}, [leads, expandedDupId]); }, [leads, expandedDupId]);
const allColumns = [ const allColumns = [
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 }, { id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 }, { id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 }, { id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
@@ -110,7 +107,7 @@ export const LeadTable = ({
]; ];
const columns = visibleColumns const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view') ? allColumns.filter(c => visibleColumns.has(c.id))
: allColumns; : allColumns;
return ( return (
@@ -156,7 +153,6 @@ export const LeadTable = ({
id={row.id} id={row.id}
className="bg-warning-primary" className="bg-warning-primary"
> >
<Table.Cell />
<Table.Cell className="pl-10"> <Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span> <span className="text-xs text-tertiary">{phone}</span>
</Table.Cell> </Table.Cell>
@@ -207,20 +203,12 @@ export const LeadTable = ({
key={row.id} key={row.id}
id={row.id} id={row.id}
className={cx( className={cx(
'group/row', '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)}
> >
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{isCol('phone') && <Table.Cell> {isCol('phone') && <Table.Cell>
{phoneRaw ? ( {phoneRaw ? (
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} /> <PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />

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

@@ -146,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]);
@@ -320,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

@@ -1,8 +1,9 @@
// Appointments v2 — lean table + detail side panel + reschedule + reminder // 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 { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark, faMagnifyingGlass, faPenToSquare, faXmark,
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical, faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
@@ -12,7 +13,6 @@ 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 { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { useData } from '@/providers/data-provider';
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';
import type { Appointment } from '@/types/entities';
type AppointmentRecord = {
id: string;
scheduledAt: string | null;
durationMin: number | null;
appointmentType: string | null;
status: string | null;
doctorName: string | null;
department: string | null;
reasonForVisit: string | null;
patient: {
id: string;
fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null;
} | null;
clinic: {
id?: string;
clinicName: string;
} | null;
doctor: {
id: string;
fullName?: { firstName: string; lastName: string } | null;
} | null;
};
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED'; type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
@@ -69,43 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
RESCHEDULED: 'Rescheduled', RESCHEDULED: 'Rescheduled',
}; };
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { const getPatientName = (appt: Appointment): string =>
id scheduledAt durationMin appointmentType status appt.patientName || 'Unknown';
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { id clinicName }
doctor { id fullName { firstName lastName } }
} } } }`;
const formatDateTime = (iso: string): string => const getPhone = (appt: Appointment): string =>
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`; appt.patientPhone ?? '';
const getPatientName = (appt: AppointmentRecord): string => { const canEdit = (appt: Appointment): boolean =>
if (!appt.patient?.fullName) return 'Unknown'; appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
};
const getPhone = (appt: AppointmentRecord): string =>
appt.patient?.phones?.primaryPhoneNumber ?? '';
const isUpcoming = (appt: AppointmentRecord): boolean => {
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
if (!appt.scheduledAt) return false;
return new Date(appt.scheduledAt).getTime() >= Date.now();
};
// Can edit/reschedule: anything that isn't completed or cancelled
const canEdit = (appt: AppointmentRecord): boolean => {
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
};
const buildReminderMessage = (appt: AppointmentRecord): string => {
const name = getPatientName(appt);
const doctor = appt.doctorName ?? 'your doctor';
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
const branch = appt.clinic?.clinicName ?? 'our clinic';
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
};
// ── Detail Panel ───────────────────────────────────────────────── // ── Detail Panel ─────────────────────────────────────────────────
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => ( const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
@@ -123,7 +72,7 @@ const AppointmentDetailPanel = ({
onClose, onClose,
onReschedule, onReschedule,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onReschedule: () => void; onReschedule: () => void;
}) => { }) => {
@@ -155,12 +104,11 @@ const AppointmentDetailPanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
<div className="mb-4"> <div className="mb-4">
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color"> <Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'} {STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
</Badge> </Badge>
</div> </div>
{/* Date & Time — 2 lines */}
<div className="flex items-start gap-3 py-2.5"> <div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" /> <FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div> <div>
@@ -176,7 +124,7 @@ const AppointmentDetailPanel = ({
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} /> <DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} /> <DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} /> <DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} /> <DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
<div className="border-t border-secondary pt-3 mt-3"> <div className="border-t border-secondary pt-3 mt-3">
@@ -190,7 +138,6 @@ const AppointmentDetailPanel = ({
</div> </div>
</div> </div>
{/* Reschedule confirm modal — same pattern as call desk */}
<ModalOverlay <ModalOverlay
isOpen={reschedulePromptOpen} isOpen={reschedulePromptOpen}
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }} onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
@@ -203,7 +150,6 @@ const AppointmentDetailPanel = ({
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2> <h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary"> <p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor. Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}> <Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
@@ -223,10 +169,6 @@ const AppointmentDetailPanel = ({
}; };
// ── Reschedule Panel ───────────────────────────────────────────── // ── Reschedule Panel ─────────────────────────────────────────────
// Dedicated form for rescheduling from the Appointments page.
// No patient creation, no lead updates, no modal — just update the
// existing appointment's doctor, date, time, and chief complaint.
type Doctor = { id: string; name: string; department: string }; type Doctor = { id: string; name: string; department: string };
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node { const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
@@ -238,13 +180,13 @@ const ReschedulePanel = ({
onClose, onClose,
onSaved, onSaved,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) => { }) => {
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [department, setDepartment] = useState(appointment.department ?? ''); const [department, setDepartment] = useState(appointment.department ?? '');
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
const [timeSlot, setTimeSlot] = useState(() => { const [timeSlot, setTimeSlot] = useState(() => {
if (!appointment.scheduledAt) return ''; if (!appointment.scheduledAt) return '';
@@ -257,7 +199,6 @@ const ReschedulePanel = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cancelConfirm, setCancelConfirm] = useState(false); const [cancelConfirm, setCancelConfirm] = useState(false);
// Fetch doctors once
useEffect(() => { useEffect(() => {
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true }) apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
.then(data => { .then(data => {
@@ -273,11 +214,9 @@ const ReschedulePanel = ({
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Departments derived from doctors
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]); const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]); const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
// Fetch slots when doctor + date change
useEffect(() => { useEffect(() => {
if (!doctor || !date) { setSlots([]); return; } if (!doctor || !date) { setSlots([]); return; }
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
@@ -346,7 +285,6 @@ const ReschedulePanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Department */}
<div> <div>
<span className="text-xs font-medium text-secondary">Department</span> <span className="text-xs font-medium text-secondary">Department</span>
<Select <Select
@@ -360,7 +298,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Doctor */}
<div> <div>
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
<Select <Select
@@ -374,7 +311,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Date */}
<div> <div>
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker <DatePicker
@@ -387,7 +323,6 @@ const ReschedulePanel = ({
/> />
</div> </div>
{/* Time slots */}
{doctor && date && slots.length > 0 && ( {doctor && date && slots.length > 0 && (
<div> <div>
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
@@ -413,7 +348,6 @@ const ReschedulePanel = ({
<p className="text-xs text-tertiary">No available slots for this date</p> <p className="text-xs text-tertiary">No available slots for this date</p>
)} )}
{/* Chief Complaint */}
<div> <div>
<span className="text-xs font-medium text-secondary">Chief Complaint</span> <span className="text-xs font-medium text-secondary">Chief Complaint</span>
<textarea <textarea
@@ -428,7 +362,6 @@ const ReschedulePanel = ({
{error && <p className="text-sm text-error-primary">{error}</p>} {error && <p className="text-sm text-error-primary">{error}</p>}
</div> </div>
{/* Footer buttons */}
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3"> <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}> <Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
Cancel Appointment Cancel Appointment
@@ -438,7 +371,6 @@ const ReschedulePanel = ({
</Button> </Button>
</div> </div>
{/* Cancel confirm modal */}
<ModalOverlay <ModalOverlay
isOpen={cancelConfirm} isOpen={cancelConfirm}
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }} onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
@@ -471,37 +403,31 @@ const ReschedulePanel = ({
// ── Page ───────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────
export const AppointmentsPageV2 = () => { export const AppointmentsPageV2 = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]); const { appointments, loading, refresh } = useData();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all'); const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null); const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false); const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const fetchAppointments = () => {
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchAppointments(); }, []);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const a of appointments) { for (const a of appointments) {
const s = a.status ?? 'UNKNOWN'; const s = a.appointmentStatus ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1; counts[s] = (counts[s] ?? 0) + 1;
} }
return counts; return counts;
}, [appointments]); }, [appointments]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let rows = appointments; let rows = [...appointments].sort((a, b) => {
if (tab !== 'all') rows = rows.filter(a => a.status === tab); 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()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(a => { rows = rows.filter(a => {
@@ -527,25 +453,17 @@ export const AppointmentsPageV2 = () => {
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined }, { id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
]; ];
const handleEditClick = (appt: AppointmentRecord) => { const handleEditClick = (appt: Appointment) => {
setSelectedAppt(appt); setSelectedAppt(appt);
setPanelOpen(true); setPanelOpen(true);
setRescheduleOpen(false); setRescheduleOpen(false);
}; };
const handleSendReminder = (appt: AppointmentRecord) => {
const phone = getPhone(appt);
if (!phone) return;
const msg = encodeURIComponent(buildReminderMessage(appt));
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
};
const handleRescheduleSaved = () => { const handleRescheduleSaved = () => {
setRescheduleOpen(false); setRescheduleOpen(false);
setPanelOpen(false); setPanelOpen(false);
setSelectedAppt(null); setSelectedAppt(null);
fetchAppointments(); refresh();
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
}; };
@@ -554,7 +472,7 @@ export const AppointmentsPageV2 = () => {
<PageHeader <PageHeader
title="Appointments" title="Appointments"
badge={filtered.length} badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule." infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
controls={ controls={
<div className="w-56"> <div className="w-56">
<Input <Input
@@ -589,7 +507,6 @@ export const AppointmentsPageV2 = () => {
<div className="flex flex-1 overflow-hidden"> <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">
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col 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">
@@ -602,47 +519,31 @@ export const AppointmentsPageV2 = () => {
) : ( ) : (
<Table size="sm"> <Table size="sm">
<Table.Header> <Table.Header>
<Table.Head label="" className="w-8" isRowHeader /> <Table.Head label="PATIENT" className="min-w-[180px]" isRowHeader />
<Table.Head label="PATIENT" className="min-w-[180px]" />
<Table.Head label="DATE & TIME" className="w-28" /> <Table.Head label="DATE & TIME" className="w-28" />
<Table.Head label="DOCTOR" className="min-w-[160px]" /> <Table.Head label="DOCTOR" className="min-w-[160px]" />
<Table.Head label="STATUS" className="w-24" /> <Table.Head label="STATUS" className="w-24" />
<Table.Head label="REMIND" className="w-20" />
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(appt) => { {(appt) => {
const name = getPatientName(appt); const name = getPatientName(appt);
const phone = getPhone(appt); const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
const upcoming = isUpcoming(appt);
const isSelected = selectedAppt?.id === appt.id; const isSelected = selectedAppt?.id === appt.id;
return ( return (
<Table.Row <Table.Row
id={appt.id} id={appt.id}
className={cx('group/row', isSelected && 'bg-brand-primary')} className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
onAction={() => handleEditClick(appt)}
> >
{/* Eye icon — first column */}
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{/* Patient: name + phone on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{name}</p> <p className="text-sm font-medium text-primary truncate">{name}</p>
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />} {phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Date & Time: date + time on 2 lines */}
<Table.Cell> <Table.Cell>
{appt.scheduledAt ? ( {appt.scheduledAt ? (
<div> <div>
@@ -651,38 +552,17 @@ export const AppointmentsPageV2 = () => {
</div> </div>
) : <span className="text-sm text-quaternary"></span>} ) : <span className="text-sm text-quaternary"></span>}
</Table.Cell> </Table.Cell>
{/* Doctor: name + department on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p> <p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>} {appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Status */}
<Table.Cell> <Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color"> <Badge size="sm" color={statusColor} type="pill-color">
{statusLabel} {statusLabel}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
{/* Reminder */}
<Table.Cell>
{upcoming ? (
<button
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
title="Send WhatsApp reminder"
>
<FontAwesomeIcon icon={faBell} className="size-3" />
Send
</button>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
@@ -699,7 +579,6 @@ export const AppointmentsPageV2 = () => {
</div> </div>
</div> </div>
{/* Detail side panel */}
<div className={cx( <div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear", "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 ? "w-[380px]" : "w-0 border-l-0",

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

@@ -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,7 +7,6 @@ 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 { PageHeader } from '@/components/layout/page-header'; 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';
@@ -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)}
@@ -252,11 +252,22 @@ export const MissedCallsPage = () => {
</> </>
} }
tabs={ tabs={
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> <div className="flex items-center gap-1.5">
<TabList items={tabItems} type="underline" size="sm"> {tabItems.map((item) => (
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} <button
</TabList> key={item.id}
</Tabs> 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>
} }
/> />
@@ -274,6 +285,7 @@ export const MissedCallsPage = () => {
<DynamicMissedCallTable <DynamicMissedCallTable
calls={pagedRows} calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))} columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor} sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor} onSortChange={setSortDescriptor}
/> />

View File

@@ -133,8 +133,6 @@ export const PatientsPage = () => {
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label="EMAIL" /> <Table.Head label="EMAIL" />
<Table.Head label="GENDER" />
<Table.Head label="AGE" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
@@ -197,19 +195,6 @@ export const PatientsPage = () => {
)} )}
</Table.Cell> </Table.Cell>
{/* Gender */}
<Table.Cell>
<span className="text-sm text-secondary">
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
</span>
</Table.Cell>
{/* Age */}
<Table.Cell>
<span className="text-sm text-secondary">
{age !== null ? `${age} yrs` : '—'}
</span>
</Table.Cell>
</Table.Row> </Table.Row>
); );

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