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