mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
20 Commits
hardening/
...
a306311f08
| Author | SHA1 | Date | |
|---|---|---|---|
| a306311f08 | |||
| d0e34fa9dd | |||
| 7e5d910197 | |||
| dd4240ee7f | |||
| 85976803a1 | |||
| 4ddad7c060 | |||
| 911ea4cd6c | |||
| 9cc71dbd95 | |||
| 0bc8271845 | |||
| eee7c82b8d | |||
| d4b0637cd5 | |||
| b3ba840dec | |||
| 275b2a6292 | |||
| 00f8f89e67 | |||
| 810eb75ccb | |||
| fd7ee4fc1f | |||
| e175735d6c | |||
| 5f3b455edc | |||
| a9d19af1d3 | |||
| b03d0f62cf |
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal file
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal 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
|
||||
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal file
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal 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)
|
||||
@@ -22,6 +22,7 @@ import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useAgentState } from '@/hooks/use-agent-state';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
@@ -41,7 +42,8 @@ const formatDuration = (seconds: number): string => {
|
||||
|
||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||
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 setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
@@ -71,7 +73,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
// Upcoming appointments for this caller (if returning patient) — drives
|
||||
// the pill row above AppointmentForm so the agent can edit existing
|
||||
// bookings in addition to creating new ones.
|
||||
const { appointments } = useData();
|
||||
const { appointments, refresh } = useData();
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -103,7 +105,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||
const { 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 wasAnsweredRef = useRef(callState === 'active');
|
||||
@@ -180,6 +186,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||
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') {
|
||||
addActions('RESCHEDULE');
|
||||
notify.success('Appointment Rescheduled');
|
||||
@@ -220,11 +231,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/* Cancel button removed per product — risk: agent can't abort
|
||||
a misdialled outbound call before the customer answers.
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -248,7 +257,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<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>
|
||||
);
|
||||
@@ -270,10 +279,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
// Active call
|
||||
if (callState === 'active' || dispositionOpen) {
|
||||
wasAnsweredRef.current = true;
|
||||
if (customerAnswered) wasAnsweredRef.current = true;
|
||||
return (
|
||||
<>
|
||||
<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 */}
|
||||
<div className="shrink-0 p-4">
|
||||
<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'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||
</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
|
||||
import { AiSuggestions, type Suggestion } from './ai-suggestions';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
@@ -16,14 +18,10 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
callerSummary?: CallerSummary | null;
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
// Supervisor has different quick-action prompts than the CC agent — they
|
||||
// ask about team metrics, not patient / doctor info. Hardcoded here rather
|
||||
// than in theme tokens because the prompts map 1:1 to the supervisor tool
|
||||
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
|
||||
// get_campaign_stats) — changing the tools means changing these prompts.
|
||||
const SUPERVISOR_QUICK_ACTIONS = [
|
||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||
@@ -33,27 +31,49 @@ const SUPERVISOR_QUICK_ACTIONS = [
|
||||
|
||||
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 isSupervisor = callerContext?.type === 'supervisor';
|
||||
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
|
||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||
api: `${API_URL}/api/ai/stream`,
|
||||
streamProtocol: 'text',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
context: callerContext,
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: { context: callerContext },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
|
||||
if (lastAssistant) {
|
||||
const parsed = parseAiResponse(lastAssistant.content);
|
||||
if (parsed.suggestions.length > 0) {
|
||||
setSuggestions(parsed.suggestions);
|
||||
}
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messagesEndRef.current;
|
||||
if (el?.parentElement) {
|
||||
@@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||
// notes history when the caller is a returning patient.
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
|
||||
// Call ended or no caller — wipe the panel so the next caller's
|
||||
// context doesn't bleed over and the agent isn't staring at a stale
|
||||
// summary in the worklist view between calls.
|
||||
if (!leadId) {
|
||||
if (autoFiredForLeadRef.current !== null) {
|
||||
autoFiredForLeadRef.current = null;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
|
||||
// New caller — clear any prior chat state and fire the summary prompt.
|
||||
autoFiredForLeadRef.current = leadId;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
const name = callerContext?.leadName ?? 'this caller';
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
||||
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
|
||||
});
|
||||
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||
|
||||
@@ -103,15 +113,37 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
append({ role: 'user', content: prompt });
|
||||
};
|
||||
|
||||
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
|
||||
});
|
||||
}, [append]);
|
||||
|
||||
// Filter out the currently-streaming assistant message (shows raw JSON).
|
||||
// Only display completed assistant messages with parsed content.
|
||||
const displayMessages = messages
|
||||
.filter((msg, i) => {
|
||||
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
|
||||
return true;
|
||||
})
|
||||
.map(msg => {
|
||||
if (msg.role === 'assistant') {
|
||||
const parsed = parseAiResponse(msg.content);
|
||||
return { ...msg, content: parsed.message };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="flex h-full flex-col gap-2 p-3">
|
||||
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 && (
|
||||
{displayMessages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-xs text-tertiary">
|
||||
{introText}
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">{introText}</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
@@ -127,18 +159,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-primary'
|
||||
}`}
|
||||
>
|
||||
{displayMessages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
|
||||
}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||
@@ -165,7 +190,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
||||
{!isSupervisor && suggestions.length > 0 && (
|
||||
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
||||
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||
<input
|
||||
@@ -188,20 +217,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
|
||||
|
||||
const parseLine = (text: string): ReactNode[] => {
|
||||
const parts: ReactNode[] = [];
|
||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = boldPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||
lastIndex = boldPattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts.length > 0 ? parts : [text];
|
||||
};
|
||||
@@ -209,7 +235,6 @@ const parseLine = (text: string): ReactNode[] => {
|
||||
const MessageContent = ({ content }: { content: string }) => {
|
||||
if (!content) return null;
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
|
||||
102
src/components/call-desk/ai-suggestions.tsx
Normal file
102
src/components/call-desk/ai-suggestions.tsx
Normal 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 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
src/components/call-desk/ai-summary-card.tsx
Normal file
88
src/components/call-desk/ai-summary-card.tsx
Normal 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)} · {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)} · {caller.lastAppointment.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,13 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
|
||||
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from './ai-chat-panel';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||
import type { Appointment } from '@/types/entities';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
|
||||
// The context panel can render for any worklist item — not just leads.
|
||||
// Missed calls and follow-ups provide a subset of the fields (phone +
|
||||
// patientId + name) without a full Lead entity. ContextPanelSubject
|
||||
// captures the minimum the panel needs to render P360.
|
||||
export type ContextPanelSubject = {
|
||||
id: string;
|
||||
contactName?: { firstName: string; lastName: string } | null;
|
||||
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||
patientId?: string | null;
|
||||
// Lead-specific fields — present when the subject IS a lead
|
||||
leadSource?: string | null;
|
||||
leadStatus?: string | null;
|
||||
aiSummary?: string | null;
|
||||
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
|
||||
|
||||
interface ContextPanelProps {
|
||||
selectedLead: ContextPanelSubject | null;
|
||||
activities: LeadActivity[];
|
||||
calls: Call[];
|
||||
followUps: FollowUp[];
|
||||
activities: any[];
|
||||
calls: any[];
|
||||
followUps: any[];
|
||||
appointments: Appointment[];
|
||||
patients: Patient[];
|
||||
patients: any[];
|
||||
callerPhone?: string;
|
||||
isInCall?: boolean;
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number): string => {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
};
|
||||
|
||||
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
||||
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faChevronUp : faChevronDown}
|
||||
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [contextExpanded, setContextExpanded] = useState(true);
|
||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||
const [recentExpanded, setRecentExpanded] = useState(true);
|
||||
export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
|
||||
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const lead = selectedLead;
|
||||
@@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
leadName: fullName,
|
||||
} : callerPhone ? { callerPhone } : undefined;
|
||||
|
||||
// Filter data for this lead
|
||||
const leadCalls = useMemo(() =>
|
||||
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5),
|
||||
[calls, lead, callerPhone],
|
||||
);
|
||||
|
||||
const leadFollowUps = useMemo(() =>
|
||||
followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||
.slice(0, 3),
|
||||
[followUps, lead],
|
||||
);
|
||||
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = lead?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
.slice(0, 3);
|
||||
}, [appointments, lead]);
|
||||
|
||||
const leadActivities = useMemo(() =>
|
||||
activities.filter(a => a.leadId === lead?.id)
|
||||
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
|
||||
.slice(0, 5),
|
||||
[activities, lead],
|
||||
);
|
||||
const handleChatStart = useCallback(() => {}, []);
|
||||
|
||||
// Linked patient
|
||||
const linkedPatient = useMemo(() =>
|
||||
patients.find(p => p.id === lead?.patientId),
|
||||
[patients, lead],
|
||||
);
|
||||
|
||||
// Auto-collapse context sections when chat starts
|
||||
const handleChatStart = useCallback(() => {
|
||||
setContextExpanded(false);
|
||||
}, []);
|
||||
|
||||
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
||||
|
||||
// Edit mode takes over the whole right panel — otherwise the
|
||||
// AppointmentForm competes with the AI panel + context blocks for
|
||||
// vertical space and gets crushed into a tiny strip at the bottom.
|
||||
// Edit mode takes over the whole right panel
|
||||
if (editingAppointment) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Lead header — always visible */}
|
||||
{lead && (
|
||||
<div className="shrink-0 border-b border-secondary">
|
||||
<button
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
{isInCall && (
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
||||
{phone && (
|
||||
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
||||
)}
|
||||
{lead.leadStatus && (
|
||||
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={contextExpanded ? faChevronUp : faChevronDown}
|
||||
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded context sections */}
|
||||
{contextExpanded && (
|
||||
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
||||
{/* AI Insight */}
|
||||
{lead.aiSummary && (
|
||||
<div>
|
||||
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
||||
{insightExpanded && (
|
||||
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
||||
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
||||
{lead.aiSuggestedAction && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign info */}
|
||||
{(lead.utmCampaign || lead.campaignId) && (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
{lead.utmCampaign ?? lead.campaignId}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
||||
{actionsExpanded && (
|
||||
<div className="space-y-1 mb-1">
|
||||
{leadAppointments.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{appt.doctorName ?? 'Appointment'}
|
||||
</span>
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
{appt.department}
|
||||
</span>
|
||||
{appt.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
— {formatShortDate(appt.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
||||
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => setEditingAppointment(appt)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{leadFollowUps.map(fu => (
|
||||
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
||||
</span>
|
||||
{fu.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1.5">
|
||||
{formatShortDate(fu.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
||||
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{linkedPatient && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
<span className="text-xs text-primary">
|
||||
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
||||
</span>
|
||||
{linkedPatient.patientType && (
|
||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
View 360
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent calls + activities */}
|
||||
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={faClockRotateLeft}
|
||||
label="Recent"
|
||||
count={leadCalls.length + leadActivities.length}
|
||||
expanded={recentExpanded}
|
||||
onToggle={() => setRecentExpanded(!recentExpanded)}
|
||||
/>
|
||||
{recentExpanded && (
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{leadCalls.map(call => (
|
||||
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<FontAwesomeIcon
|
||||
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
||||
className={cx('size-3 shrink-0',
|
||||
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
||||
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs text-primary">
|
||||
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
||||
</span>
|
||||
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
||||
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
||||
)}
|
||||
{call.disposition && (
|
||||
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-quaternary shrink-0">
|
||||
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{leadActivities
|
||||
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
||||
.slice(0, 3)
|
||||
.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
||||
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
||||
{a.occurredAt && (
|
||||
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No context available */}
|
||||
{!hasContext && (
|
||||
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat — fills remaining space */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useSip } from '@/providers/sip-provider';
|
||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
@@ -16,6 +15,7 @@ import { useData } from '@/providers/data-provider';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
// import { GlobalSearch } from '@/components/shared/global-search';
|
||||
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -118,18 +118,10 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<div className="flex h-screen bg-primary">
|
||||
<Sidebar activeUrl={pathname} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Persistent top bar — visible on all pages */}
|
||||
{(hasAgentConfig || isAdmin) && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||
{/* GlobalSearch hidden — navigation on result click
|
||||
routes to Patient 360 with stale appointment state
|
||||
from the call desk. Revisit when the Patient 360
|
||||
route properly resets context on mount. (#4) */}
|
||||
{/* <GlobalSearch /> */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isAdmin && <NotificationBell />}
|
||||
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
@@ -145,8 +137,6 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -154,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
{isAdmin && <AiFloatingButton />}
|
||||
</div>
|
||||
<MaintOtpModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -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">
|
||||
{/* Row 1: title + controls */}
|
||||
<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} />}
|
||||
</div>
|
||||
{controls && (
|
||||
<div className="flex items-center gap-2">
|
||||
{controls}
|
||||
{isAdmin && <NotificationBell />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
|
||||
@@ -96,3 +99,4 @@ export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }:
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||
@@ -94,7 +92,6 @@ export const LeadTable = ({
|
||||
}, [leads, expandedDupId]);
|
||||
|
||||
const allColumns = [
|
||||
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
|
||||
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
||||
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
||||
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
||||
@@ -110,7 +107,7 @@ export const LeadTable = ({
|
||||
];
|
||||
|
||||
const columns = visibleColumns
|
||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
|
||||
? allColumns.filter(c => visibleColumns.has(c.id))
|
||||
: allColumns;
|
||||
|
||||
return (
|
||||
@@ -156,7 +153,6 @@ export const LeadTable = ({
|
||||
id={row.id}
|
||||
className="bg-warning-primary"
|
||||
>
|
||||
<Table.Cell />
|
||||
<Table.Cell className="pl-10">
|
||||
<span className="text-xs text-tertiary">{phone}</span>
|
||||
</Table.Cell>
|
||||
@@ -207,20 +203,12 @@ export const LeadTable = ({
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
className={cx(
|
||||
'group/row',
|
||||
'group/row cursor-pointer',
|
||||
isSpamRow && !isSelected && 'bg-warning-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>
|
||||
{phoneRaw ? (
|
||||
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
||||
|
||||
@@ -17,6 +17,7 @@ type SectionCardProps = {
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
status?: SectionStatus;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||
@@ -30,26 +31,32 @@ export const SectionCard = ({
|
||||
href,
|
||||
onClick,
|
||||
status = 'unknown',
|
||||
disabled = false,
|
||||
}: SectionCardProps) => {
|
||||
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 = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
|
||||
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
|
||||
<p className="mt-1 text-xs text-tertiary">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status !== 'unknown' && (
|
||||
@@ -70,6 +77,13 @@ export const SectionCard = ({
|
||||
</>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
|
||||
50
src/components/shared/ai-floating-button.tsx
Normal file
50
src/components/shared/ai-floating-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -146,9 +146,7 @@ export const AllLeadsPage = () => {
|
||||
result = result.filter((l) => l.assignedAgent === user.name);
|
||||
}
|
||||
if (campaignFilter) {
|
||||
result = campaignFilter === '__none__'
|
||||
? result.filter((l) => !l.campaignId)
|
||||
: result.filter((l) => l.campaignId === campaignFilter);
|
||||
result = result.filter((l) => l.campaignId === campaignFilter);
|
||||
}
|
||||
return result;
|
||||
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
||||
@@ -320,17 +318,6 @@ export const AllLeadsPage = () => {
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
|
||||
faMagnifyingGlass, faPenToSquare, faXmark,
|
||||
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
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 { Table } from '@/components/application/table/table';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
// TopBar replaced by inline header
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
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;
|
||||
};
|
||||
import type { Appointment } from '@/types/entities';
|
||||
|
||||
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||
|
||||
@@ -69,43 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
RESCHEDULED: 'Rescheduled',
|
||||
};
|
||||
|
||||
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { id clinicName }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
} } } }`;
|
||||
const getPatientName = (appt: Appointment): string =>
|
||||
appt.patientName || 'Unknown';
|
||||
|
||||
const formatDateTime = (iso: string): string =>
|
||||
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
|
||||
const getPhone = (appt: Appointment): string =>
|
||||
appt.patientPhone ?? '';
|
||||
|
||||
const getPatientName = (appt: AppointmentRecord): string => {
|
||||
if (!appt.patient?.fullName) return 'Unknown';
|
||||
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
|
||||
};
|
||||
|
||||
const getPhone = (appt: AppointmentRecord): string =>
|
||||
appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
|
||||
const isUpcoming = (appt: AppointmentRecord): boolean => {
|
||||
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
|
||||
if (!appt.scheduledAt) return false;
|
||||
return new Date(appt.scheduledAt).getTime() >= Date.now();
|
||||
};
|
||||
|
||||
// Can edit/reschedule: anything that isn't completed or cancelled
|
||||
const canEdit = (appt: AppointmentRecord): boolean => {
|
||||
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
||||
};
|
||||
|
||||
const buildReminderMessage = (appt: AppointmentRecord): string => {
|
||||
const name = getPatientName(appt);
|
||||
const doctor = appt.doctorName ?? 'your doctor';
|
||||
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
|
||||
const branch = appt.clinic?.clinicName ?? 'our clinic';
|
||||
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
|
||||
};
|
||||
const canEdit = (appt: Appointment): boolean =>
|
||||
appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
|
||||
|
||||
// ── Detail Panel ─────────────────────────────────────────────────
|
||||
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||
@@ -123,7 +72,7 @@ const AppointmentDetailPanel = ({
|
||||
onClose,
|
||||
onReschedule,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onReschedule: () => void;
|
||||
}) => {
|
||||
@@ -155,12 +104,11 @@ const AppointmentDetailPanel = ({
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||
<div className="mb-4">
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Date & Time — 2 lines */}
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
@@ -176,7 +124,7 @@ const AppointmentDetailPanel = ({
|
||||
|
||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
|
||||
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||
|
||||
<div className="border-t border-secondary pt-3 mt-3">
|
||||
@@ -190,7 +138,6 @@ const AppointmentDetailPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — same pattern as call desk */}
|
||||
<ModalOverlay
|
||||
isOpen={reschedulePromptOpen}
|
||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||
@@ -203,7 +150,6 @@ const AppointmentDetailPanel = ({
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
Choose "No, just view" to see the details without changing anything.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
||||
@@ -223,10 +169,6 @@ const AppointmentDetailPanel = ({
|
||||
};
|
||||
|
||||
// ── Reschedule Panel ─────────────────────────────────────────────
|
||||
// Dedicated form for rescheduling from the Appointments page.
|
||||
// No patient creation, no lead updates, no modal — just update the
|
||||
// existing appointment's doctor, date, time, and chief complaint.
|
||||
|
||||
type Doctor = { id: string; name: string; department: string };
|
||||
|
||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
@@ -238,13 +180,13 @@ const ReschedulePanel = ({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
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 [timeSlot, setTimeSlot] = useState(() => {
|
||||
if (!appointment.scheduledAt) return '';
|
||||
@@ -257,7 +199,6 @@ const ReschedulePanel = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
|
||||
// Fetch doctors once
|
||||
useEffect(() => {
|
||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
@@ -273,11 +214,9 @@ const ReschedulePanel = ({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Departments derived from doctors
|
||||
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
|
||||
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
|
||||
|
||||
// Fetch slots when doctor + date change
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) { setSlots([]); return; }
|
||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||
@@ -346,7 +285,6 @@ const ReschedulePanel = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* Department */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Department</span>
|
||||
<Select
|
||||
@@ -360,7 +298,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Doctor */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||
<Select
|
||||
@@ -374,7 +311,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
@@ -387,7 +323,6 @@ const ReschedulePanel = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{doctor && date && slots.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||
@@ -413,7 +348,6 @@ const ReschedulePanel = ({
|
||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||
)}
|
||||
|
||||
{/* Chief Complaint */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||
<textarea
|
||||
@@ -428,7 +362,6 @@ const ReschedulePanel = ({
|
||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
|
||||
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
|
||||
Cancel Appointment
|
||||
@@ -438,7 +371,6 @@ const ReschedulePanel = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cancel confirm modal */}
|
||||
<ModalOverlay
|
||||
isOpen={cancelConfirm}
|
||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||
@@ -471,37 +403,31 @@ const ReschedulePanel = ({
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────
|
||||
export const AppointmentsPageV2 = () => {
|
||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { appointments, loading, refresh } = useData();
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
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 [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const fetchAppointments = () => {
|
||||
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAppointments(); }, []);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of appointments) {
|
||||
const s = a.status ?? 'UNKNOWN';
|
||||
const s = a.appointmentStatus ?? 'UNKNOWN';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [appointments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = appointments;
|
||||
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
||||
let rows = [...appointments].sort((a, b) => {
|
||||
const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
|
||||
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(a => {
|
||||
@@ -527,25 +453,17 @@ export const AppointmentsPageV2 = () => {
|
||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||
];
|
||||
|
||||
const handleEditClick = (appt: AppointmentRecord) => {
|
||||
const handleEditClick = (appt: Appointment) => {
|
||||
setSelectedAppt(appt);
|
||||
setPanelOpen(true);
|
||||
setRescheduleOpen(false);
|
||||
};
|
||||
|
||||
const handleSendReminder = (appt: AppointmentRecord) => {
|
||||
const phone = getPhone(appt);
|
||||
if (!phone) return;
|
||||
const msg = encodeURIComponent(buildReminderMessage(appt));
|
||||
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
|
||||
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
|
||||
};
|
||||
|
||||
const handleRescheduleSaved = () => {
|
||||
setRescheduleOpen(false);
|
||||
setPanelOpen(false);
|
||||
setSelectedAppt(null);
|
||||
fetchAppointments();
|
||||
refresh();
|
||||
notify.success('Appointment Rescheduled');
|
||||
};
|
||||
|
||||
@@ -554,7 +472,7 @@ export const AppointmentsPageV2 = () => {
|
||||
<PageHeader
|
||||
title="Appointments"
|
||||
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={
|
||||
<div className="w-56">
|
||||
<Input
|
||||
@@ -589,7 +507,6 @@ export const AppointmentsPageV2 = () => {
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -602,47 +519,31 @@ export const AppointmentsPageV2 = () => {
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="" className="w-8" isRowHeader />
|
||||
<Table.Head label="PATIENT" className="min-w-[180px]" />
|
||||
<Table.Head label="PATIENT" className="min-w-[180px]" isRowHeader />
|
||||
<Table.Head label="DATE & TIME" className="w-28" />
|
||||
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
||||
<Table.Head label="STATUS" className="w-24" />
|
||||
<Table.Head label="REMIND" className="w-20" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(appt) => {
|
||||
const name = getPatientName(appt);
|
||||
const phone = getPhone(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
const upcoming = isUpcoming(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
|
||||
const isSelected = selectedAppt?.id === appt.id;
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
id={appt.id}
|
||||
className={cx('group/row', 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>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Date & Time: date + time on 2 lines */}
|
||||
<Table.Cell>
|
||||
{appt.scheduledAt ? (
|
||||
<div>
|
||||
@@ -651,38 +552,17 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
) : <span className="text-sm text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Doctor: name + department on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Reminder */}
|
||||
<Table.Cell>
|
||||
{upcoming ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
|
||||
title="Send WhatsApp reminder"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} className="size-3" />
|
||||
Send
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
@@ -699,7 +579,6 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail side panel */}
|
||||
<div className={cx(
|
||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||
@@ -26,6 +26,7 @@ type RecordingRecord = {
|
||||
callStatus: string | null;
|
||||
callerNumber: { primaryPhoneNumber: string } | null;
|
||||
agentName: string | null;
|
||||
agent: { id: string; name: string; ozonetelAgentId: string } | null;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
disposition: string | null;
|
||||
@@ -35,7 +36,8 @@ type RecordingRecord = {
|
||||
|
||||
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
|
||||
agentName startedAt durationSec disposition sla
|
||||
agentName agent { id name ozonetelAgentId }
|
||||
startedAt durationSec disposition sla
|
||||
recording { primaryLinkUrl primaryLinkLabel }
|
||||
} } } }`;
|
||||
|
||||
@@ -109,7 +111,7 @@ export const CallRecordingsPage = () => {
|
||||
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
switch (colId) {
|
||||
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':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
@@ -207,7 +209,7 @@ export const CallRecordingsPage = () => {
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(c =>
|
||||
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||
(c.disposition ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
@@ -217,7 +219,7 @@ export const CallRecordingsPage = () => {
|
||||
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortDescriptor.column) {
|
||||
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||
case 'agent': return (a.agent?.name ?? a.agentName ?? '').localeCompare(b.agent?.name ?? b.agentName ?? '') * dir;
|
||||
case 'dateTime': {
|
||||
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
@@ -238,19 +240,20 @@ export const CallRecordingsPage = () => {
|
||||
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Call Recordings" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
title="Call Recordings"
|
||||
badge={filtered.length}
|
||||
infoText="All call recordings with AI analysis, dispositions, and playback."
|
||||
controls={
|
||||
<>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
@@ -264,7 +267,7 @@ export const CallRecordingsPage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<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}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
@@ -278,7 +281,7 @@ export const CallRecordingsPage = () => {
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={activeColumns}>
|
||||
<Table.Row id={call.id} columns={activeColumns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderRecordingCell(call, col.id)}
|
||||
@@ -323,6 +326,5 @@ export const CallRecordingsPage = () => {
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,9 +73,40 @@ export const CampaignDetailPage = () => {
|
||||
|
||||
<KpiStrip campaign={campaign} />
|
||||
|
||||
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
|
||||
<div className="px-7 pt-5 pb-7">
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
|
||||
{/* Campaign details + funnel + source — horizontal cards above table */}
|
||||
<div className="px-7 pt-5">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 mb-6">
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
{[
|
||||
['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
|
||||
['Platform', campaign.platform ?? '--'],
|
||||
['Start', formatDateShort(campaign.startDate)],
|
||||
['End', formatDateShort(campaign.endDate)],
|
||||
['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
|
||||
['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>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-3 space-y-2">
|
||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leads table — full width */}
|
||||
<div className="px-7 pb-7">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -96,6 +127,7 @@ export const CampaignDetailPage = () => {
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -113,68 +145,6 @@ export const CampaignDetailPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
||||
<dl className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Type</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Platform</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.platform ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Start Date</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{formatDateShort(campaign.startDate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">End Date</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{formatDateShort(campaign.endDate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Budget</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.budget
|
||||
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
|
||||
: '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Impressions</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Clicks</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activityLead && (
|
||||
|
||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
isDisabled
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { BargeControls } from '@/components/call-desk/barge-controls';
|
||||
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const { leads } = useData();
|
||||
|
||||
// Poll active calls every 5 seconds
|
||||
// Initial load + SSE stream for real-time active call updates
|
||||
useEffect(() => {
|
||||
const fetchCalls = () => {
|
||||
// Initial snapshot
|
||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||
.then(calls => {
|
||||
setActiveCalls(calls);
|
||||
// If selected call ended, clear selection
|
||||
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
|
||||
.then(setActiveCalls)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// SSE stream — receives update/remove events in real-time
|
||||
const apiUrl = import.meta.env.VITE_API_URL ?? '';
|
||||
const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
|
||||
es.onmessage = (msg) => {
|
||||
try {
|
||||
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
|
||||
setActiveCalls(prev => {
|
||||
if (event.type === 'remove') {
|
||||
return prev.filter(c => c.ucid !== event.ucid);
|
||||
}
|
||||
if (event.type === 'update' && event.call) {
|
||||
const exists = prev.find(c => c.ucid === event.ucid);
|
||||
if (exists) {
|
||||
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
|
||||
}
|
||||
return [...prev, event.call];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// SSE reconnects automatically; no-op
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
// Clear selection if the selected call ended
|
||||
useEffect(() => {
|
||||
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
|
||||
setSelectedCall(null);
|
||||
setCallerContext(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
fetchCalls();
|
||||
const interval = setInterval(fetchCalls, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedCall?.ucid]);
|
||||
}, [activeCalls, selectedCall]);
|
||||
|
||||
// Tick every second for duration display
|
||||
useEffect(() => {
|
||||
@@ -160,7 +182,11 @@ export const LiveMonitorPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
|
||||
<PageHeader
|
||||
title="Live Call Monitor"
|
||||
badge={activeCalls.length}
|
||||
infoText="Monitor, whisper, or barge into active calls in real-time."
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel — KPIs + call list */}
|
||||
|
||||
@@ -7,7 +7,6 @@ const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
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[];
|
||||
columns: ColDef[];
|
||||
columnKey: string;
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (desc: SortDescriptor) => void;
|
||||
}) => (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table.Header columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
@@ -146,7 +146,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
|
||||
</Table.Header>
|
||||
<Table.Body items={calls}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={columns}>
|
||||
<Table.Row id={call.id} columns={columns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderCell(call, col.id)}
|
||||
@@ -252,11 +252,22 @@ export const MissedCallsPage = () => {
|
||||
</>
|
||||
}
|
||||
tabs={
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
|
||||
<TabList items={tabItems} type="underline" size="sm">
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleTab(item.id)}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
tab === item.id
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
{item.label}{item.badge ? ` ${item.badge}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -274,6 +285,7 @@ export const MissedCallsPage = () => {
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
columnKey={Array.from(visibleColumns).sort().join(',')}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
|
||||
@@ -133,8 +133,6 @@ export const PatientsPage = () => {
|
||||
<Table.Head label="PATIENT" isRowHeader />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="EMAIL" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||
{(patient) => {
|
||||
@@ -197,19 +195,6 @@ export const PatientsPage = () => {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
faPalette,
|
||||
faShieldHalved,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { SectionCard } from '@/components/setup/section-card';
|
||||
import {
|
||||
SETUP_STEP_NAMES,
|
||||
@@ -50,7 +50,7 @@ export const SettingsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Settings" subtitle="Configure your hospital workspace" />
|
||||
<PageHeader title="Settings" infoText="Configure your hospital workspace." />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
|
||||
icon={faBuilding}
|
||||
href="/settings/clinics"
|
||||
status={STEP_TO_STATUS(state, 'clinics')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.doctors.title}
|
||||
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
|
||||
icon={faStethoscope}
|
||||
href="/settings/doctors"
|
||||
status={STEP_TO_STATUS(state, 'doctors')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.team.title}
|
||||
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
|
||||
icon={faUserTie}
|
||||
href="/settings/team"
|
||||
status={STEP_TO_STATUS(state, 'team')}
|
||||
disabled
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
|
||||
icon={faPhone}
|
||||
href="/settings/telephony"
|
||||
status={STEP_TO_STATUS(state, 'telephony')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.ai.title}
|
||||
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
|
||||
icon={faRobot}
|
||||
href="/settings/ai"
|
||||
status={STEP_TO_STATUS(state, 'ai')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title="Website widget"
|
||||
description="Embed the chat + booking widget on your hospital website."
|
||||
icon={faGlobe}
|
||||
href="/settings/widget"
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title="Routing rules"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||
import {
|
||||
@@ -28,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
|
||||
export const TeamDashboardPage = () => {
|
||||
const { calls, leads, campaigns, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
const [aiOpen, setAiOpen] = useState(true);
|
||||
|
||||
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
||||
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
||||
@@ -55,13 +52,11 @@ export const TeamDashboardPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
|
||||
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="Team Dashboard"
|
||||
subtitle={dateRangeLabel}
|
||||
infoText="Aggregated call metrics, agent performance, and operational alerts."
|
||||
controls={
|
||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||
{(['today', 'week', 'month'] as const).map((range) => (
|
||||
<button
|
||||
@@ -76,15 +71,8 @@ export const TeamDashboardPage = () => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiOpen(!aiOpen)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
|
||||
>
|
||||
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main content — scrollable column with KPIs pinned at the
|
||||
@@ -153,17 +141,6 @@ export const TeamDashboardPage = () => {
|
||||
</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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
|
||||
faPercent, faTriangleExclamation,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -291,25 +291,28 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Team Performance" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader title="Team Dashboard" infoText="Aggregated metrics across all agents." />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading team performance...</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Team Dashboard"
|
||||
infoText="Aggregated metrics across all agents."
|
||||
controls={<DateFilter value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Section 1: Key Metrics */}
|
||||
<div className="px-6 pt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||
<DateFilter value={range} onChange={setRange} />
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||
@@ -510,6 +513,6 @@ export const TeamPerformancePage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user