mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
4 Commits
fd7ee4fc1f
...
v0.13-ai-c
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b0637cd5 | |||
| b3ba840dec | |||
| 275b2a6292 | |||
| 00f8f89e67 |
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)
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
|
||||
import { AiSuggestions, type Suggestion } from './ai-suggestions';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
@@ -16,14 +18,10 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
callerSummary?: CallerSummary | null;
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
// Supervisor has different quick-action prompts than the CC agent — they
|
||||
// ask about team metrics, not patient / doctor info. Hardcoded here rather
|
||||
// than in theme tokens because the prompts map 1:1 to the supervisor tool
|
||||
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
|
||||
// get_campaign_stats) — changing the tools means changing these prompts.
|
||||
const SUPERVISOR_QUICK_ACTIONS = [
|
||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||
@@ -33,27 +31,49 @@ const SUPERVISOR_QUICK_ACTIONS = [
|
||||
|
||||
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
|
||||
|
||||
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||
const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => {
|
||||
const trimmed = content.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed.message) {
|
||||
return {
|
||||
message: parsed.message,
|
||||
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return { message: content, suggestions: [] };
|
||||
};
|
||||
|
||||
export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => {
|
||||
const { tokens } = useThemeTokens();
|
||||
const isSupervisor = callerContext?.type === 'supervisor';
|
||||
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
|
||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||
api: `${API_URL}/api/ai/stream`,
|
||||
streamProtocol: 'text',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
context: callerContext,
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: { context: callerContext },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
|
||||
if (lastAssistant) {
|
||||
const parsed = parseAiResponse(lastAssistant.content);
|
||||
if (parsed.suggestions.length > 0) {
|
||||
setSuggestions(parsed.suggestions);
|
||||
}
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messagesEndRef.current;
|
||||
if (el?.parentElement) {
|
||||
@@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||
// notes history when the caller is a returning patient.
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
|
||||
// Call ended or no caller — wipe the panel so the next caller's
|
||||
// context doesn't bleed over and the agent isn't staring at a stale
|
||||
// summary in the worklist view between calls.
|
||||
if (!leadId) {
|
||||
if (autoFiredForLeadRef.current !== null) {
|
||||
autoFiredForLeadRef.current = null;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
|
||||
// New caller — clear any prior chat state and fire the summary prompt.
|
||||
autoFiredForLeadRef.current = leadId;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
const name = callerContext?.leadName ?? 'this caller';
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
||||
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
|
||||
});
|
||||
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||
|
||||
@@ -103,15 +113,37 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
append({ role: 'user', content: prompt });
|
||||
};
|
||||
|
||||
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
|
||||
});
|
||||
}, [append]);
|
||||
|
||||
// Filter out the currently-streaming assistant message (shows raw JSON).
|
||||
// Only display completed assistant messages with parsed content.
|
||||
const displayMessages = messages
|
||||
.filter((msg, i) => {
|
||||
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
|
||||
return true;
|
||||
})
|
||||
.map(msg => {
|
||||
if (msg.role === 'assistant') {
|
||||
const parsed = parseAiResponse(msg.content);
|
||||
return { ...msg, content: parsed.message };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="flex h-full flex-col gap-2 p-3">
|
||||
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 && (
|
||||
{displayMessages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-xs text-tertiary">
|
||||
{introText}
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">{introText}</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
@@ -127,18 +159,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-primary'
|
||||
}`}
|
||||
>
|
||||
{displayMessages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
|
||||
}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||
@@ -165,7 +190,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
||||
{!isSupervisor && suggestions.length > 0 && (
|
||||
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
||||
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||
<input
|
||||
@@ -188,20 +217,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
|
||||
|
||||
const parseLine = (text: string): ReactNode[] => {
|
||||
const parts: ReactNode[] = [];
|
||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = boldPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||
lastIndex = boldPattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts.length > 0 ? parts : [text];
|
||||
};
|
||||
@@ -209,7 +235,6 @@ const parseLine = (text: string): ReactNode[] => {
|
||||
const MessageContent = ({ content }: { content: string }) => {
|
||||
if (!content) return null;
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
|
||||
102
src/components/call-desk/ai-suggestions.tsx
Normal file
102
src/components/call-desk/ai-suggestions.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTag, faArrowUp, faRotate, faClipboardCheck, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export type Suggestion = {
|
||||
id: string;
|
||||
type: 'upsell' | 'crosssell' | 'retention' | 'operational';
|
||||
title: string;
|
||||
script: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
interface AiSuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onTellMeMore: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
upsell: faArrowUp,
|
||||
crosssell: faTag,
|
||||
retention: faRotate,
|
||||
operational: faClipboardCheck,
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
high: 'bg-error-solid',
|
||||
medium: 'bg-warning-solid',
|
||||
low: 'bg-success-solid',
|
||||
};
|
||||
|
||||
export const AiSuggestions = ({ suggestions, onTellMeMore }: AiSuggestionsProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left"
|
||||
>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
Suggestions ({suggestions.length})
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={collapsed ? faChevronDown : faChevronUp}
|
||||
className="size-2.5 text-fg-quaternary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-1 px-2 pb-2">
|
||||
{suggestions.map((s) => {
|
||||
const isExpanded = expandedId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cx(
|
||||
'rounded-lg border transition duration-100 ease-linear',
|
||||
isExpanded ? 'border-brand bg-brand-primary' : 'border-secondary hover:border-tertiary',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : s.id)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={TYPE_ICONS[s.type]}
|
||||
className="size-3 text-fg-brand-secondary shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-primary truncate">
|
||||
{s.title}
|
||||
</span>
|
||||
<span className={cx('size-1.5 rounded-full shrink-0', PRIORITY_COLORS[s.priority])} />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2.5 pb-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed mb-2">
|
||||
{s.script}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTellMeMore(s);
|
||||
}}
|
||||
className="text-[10px] font-medium text-brand-secondary hover:text-brand-primary transition"
|
||||
>
|
||||
Tell me more →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
src/components/call-desk/ai-summary-card.tsx
Normal file
88
src/components/call-desk/ai-summary-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser, faCalendarCheck, faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
|
||||
export type CallerSummary = {
|
||||
name: string;
|
||||
phone: string;
|
||||
isNew: boolean;
|
||||
aiSummary?: string | null;
|
||||
leadSource?: string | null;
|
||||
utmCampaign?: string | null;
|
||||
nextAppointment?: { scheduledAt: string; doctorName: string; department: string } | null;
|
||||
lastAppointment?: { scheduledAt: string; status: string; department: string } | null;
|
||||
};
|
||||
|
||||
interface AiSummaryCardProps {
|
||||
caller: CallerSummary | null;
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
export const AiSummaryCard = ({ caller }: AiSummaryCardProps) => {
|
||||
if (!caller) {
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||
<p className="text-xs text-quaternary text-center">Select a patient or receive a call</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-secondary_alt p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-primary">
|
||||
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-primary truncate">{caller.name || caller.phone}</span>
|
||||
<Badge size="sm" color={caller.isNew ? 'brand' : 'success'} type="pill-color">
|
||||
{caller.isNew ? 'New' : 'Returning'}
|
||||
</Badge>
|
||||
</div>
|
||||
{caller.name && (
|
||||
<span className="text-[10px] text-tertiary">{caller.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{caller.aiSummary && (
|
||||
<p className="text-xs text-secondary leading-relaxed line-clamp-2">{caller.aiSummary}</p>
|
||||
)}
|
||||
|
||||
{(caller.leadSource || caller.utmCampaign) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{caller.leadSource && (
|
||||
<Badge size="sm" color="gray" type="pill-color">{caller.leadSource}</Badge>
|
||||
)}
|
||||
{caller.utmCampaign && (
|
||||
<Badge size="sm" color="purple" type="pill-color">{caller.utmCampaign}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{caller.nextAppointment && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-success-primary px-2 py-1">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-2.5 text-fg-success-primary" />
|
||||
<span className="text-[10px] font-medium text-success-primary">
|
||||
{formatDate(caller.nextAppointment.scheduledAt)} · {caller.nextAppointment.doctorName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{caller.lastAppointment && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-secondary px-2 py-1">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-2.5 text-fg-quaternary" />
|
||||
<span className="text-[10px] text-tertiary">
|
||||
Last: {formatDate(caller.lastAppointment.scheduledAt)} · {caller.lastAppointment.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,13 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
|
||||
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from './ai-chat-panel';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||
import type { Appointment } from '@/types/entities';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
|
||||
// The context panel can render for any worklist item — not just leads.
|
||||
// Missed calls and follow-ups provide a subset of the fields (phone +
|
||||
// patientId + name) without a full Lead entity. ContextPanelSubject
|
||||
// captures the minimum the panel needs to render P360.
|
||||
export type ContextPanelSubject = {
|
||||
id: string;
|
||||
contactName?: { firstName: string; lastName: string } | null;
|
||||
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||
patientId?: string | null;
|
||||
// Lead-specific fields — present when the subject IS a lead
|
||||
leadSource?: string | null;
|
||||
leadStatus?: string | null;
|
||||
aiSummary?: string | null;
|
||||
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
|
||||
|
||||
interface ContextPanelProps {
|
||||
selectedLead: ContextPanelSubject | null;
|
||||
activities: LeadActivity[];
|
||||
calls: Call[];
|
||||
followUps: FollowUp[];
|
||||
activities: any[];
|
||||
calls: any[];
|
||||
followUps: any[];
|
||||
appointments: Appointment[];
|
||||
patients: Patient[];
|
||||
patients: any[];
|
||||
callerPhone?: string;
|
||||
isInCall?: boolean;
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number): string => {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
};
|
||||
|
||||
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
||||
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faChevronUp : faChevronDown}
|
||||
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [contextExpanded, setContextExpanded] = useState(true);
|
||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||
const [recentExpanded, setRecentExpanded] = useState(true);
|
||||
export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
|
||||
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const lead = selectedLead;
|
||||
@@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
leadName: fullName,
|
||||
} : callerPhone ? { callerPhone } : undefined;
|
||||
|
||||
// Filter data for this lead
|
||||
const leadCalls = useMemo(() =>
|
||||
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5),
|
||||
[calls, lead, callerPhone],
|
||||
);
|
||||
|
||||
const leadFollowUps = useMemo(() =>
|
||||
followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||
.slice(0, 3),
|
||||
[followUps, lead],
|
||||
);
|
||||
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = lead?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
.slice(0, 3);
|
||||
}, [appointments, lead]);
|
||||
|
||||
const leadActivities = useMemo(() =>
|
||||
activities.filter(a => a.leadId === lead?.id)
|
||||
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
|
||||
.slice(0, 5),
|
||||
[activities, lead],
|
||||
);
|
||||
const handleChatStart = useCallback(() => {}, []);
|
||||
|
||||
// Linked patient
|
||||
const linkedPatient = useMemo(() =>
|
||||
patients.find(p => p.id === lead?.patientId),
|
||||
[patients, lead],
|
||||
);
|
||||
|
||||
// Auto-collapse context sections when chat starts
|
||||
const handleChatStart = useCallback(() => {
|
||||
setContextExpanded(false);
|
||||
}, []);
|
||||
|
||||
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
||||
|
||||
// Edit mode takes over the whole right panel — otherwise the
|
||||
// AppointmentForm competes with the AI panel + context blocks for
|
||||
// vertical space and gets crushed into a tiny strip at the bottom.
|
||||
// Edit mode takes over the whole right panel
|
||||
if (editingAppointment) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -178,199 +90,28 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
);
|
||||
}
|
||||
|
||||
// Build callerSummary for the AI coaching panel
|
||||
const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date());
|
||||
const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED');
|
||||
const callerSummary = lead ? {
|
||||
name: fullName,
|
||||
phone: phone?.number ?? callerPhone ?? '',
|
||||
isNew: false,
|
||||
aiSummary: (lead as any).aiSummary ?? null,
|
||||
leadSource: (lead as any).leadSource ?? null,
|
||||
utmCampaign: (lead as any).utmCampaign ?? null,
|
||||
nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null,
|
||||
lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null,
|
||||
} : callerPhone ? {
|
||||
name: '',
|
||||
phone: callerPhone,
|
||||
isNew: true,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Lead header — always visible */}
|
||||
{lead && (
|
||||
<div className="shrink-0 border-b border-secondary">
|
||||
<button
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
{isInCall && (
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
||||
{phone && (
|
||||
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
||||
)}
|
||||
{lead.leadStatus && (
|
||||
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={contextExpanded ? faChevronUp : faChevronDown}
|
||||
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded context sections */}
|
||||
{contextExpanded && (
|
||||
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
||||
{/* AI Insight */}
|
||||
{lead.aiSummary && (
|
||||
<div>
|
||||
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
||||
{insightExpanded && (
|
||||
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
||||
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
||||
{lead.aiSuggestedAction && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign info */}
|
||||
{(lead.utmCampaign || lead.campaignId) && (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
{lead.utmCampaign ?? lead.campaignId}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
||||
{actionsExpanded && (
|
||||
<div className="space-y-1 mb-1">
|
||||
{leadAppointments.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{appt.doctorName ?? 'Appointment'}
|
||||
</span>
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
{appt.department}
|
||||
</span>
|
||||
{appt.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
— {formatShortDate(appt.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
||||
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => setEditingAppointment(appt)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{leadFollowUps.map(fu => (
|
||||
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
||||
</span>
|
||||
{fu.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1.5">
|
||||
{formatShortDate(fu.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
||||
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{linkedPatient && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
<span className="text-xs text-primary">
|
||||
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
||||
</span>
|
||||
{linkedPatient.patientType && (
|
||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
View 360
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent calls + activities */}
|
||||
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={faClockRotateLeft}
|
||||
label="Recent"
|
||||
count={leadCalls.length + leadActivities.length}
|
||||
expanded={recentExpanded}
|
||||
onToggle={() => setRecentExpanded(!recentExpanded)}
|
||||
/>
|
||||
{recentExpanded && (
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{leadCalls.map(call => (
|
||||
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<FontAwesomeIcon
|
||||
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
||||
className={cx('size-3 shrink-0',
|
||||
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
||||
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs text-primary">
|
||||
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
||||
</span>
|
||||
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
||||
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
||||
)}
|
||||
{call.disposition && (
|
||||
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-quaternary shrink-0">
|
||||
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{leadActivities
|
||||
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
||||
.slice(0, 3)
|
||||
.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
||||
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
||||
{a.occurredAt && (
|
||||
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No context available */}
|
||||
{!hasContext && (
|
||||
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat — fills remaining space */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user