14 Commits

Author SHA1 Message Date
eee7c82b8d merge: hardening/apr-week3 → master (v0.13-ai-coaching)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
AI coaching panel:
- 3-zone panel: summary card, rule-driven suggestions, contextual chat
- Structured JSON responses via Output.object schema enforcement
- Suggestions below chat, no raw JSON during streaming
- P360 tab toggle removed — single coaching surface
- Design spec + implementation plan committed

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:31:30 +05:30
25 changed files with 1183 additions and 776 deletions

View File

@@ -0,0 +1,140 @@
# AI Coaching Panel Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the AI chat panel with a three-zone coaching surface — structured summary card, rule-driven suggestions with scripts, and contextual chat with progressive suggestion updates.
**Architecture:** CallerContextService (already built) pre-fetches caller data into Redis. Rules engine evaluates caller facts against seeded suggestion rules, producing triggers. AI system prompt includes caller context + suggestion triggers + structured output instructions. Every AI response returns `{ message, suggestions }` JSON. Frontend parses and renders across three zones.
**Tech Stack:** React 19 + Tailwind (frontend), NestJS + Vercel AI SDK + json-rules-engine + Redis (sidecar), FontAwesome Pro icons
---
## File Structure
### Sidecar (helix-engage-server)
| File | Responsibility |
|------|----------------|
| `src/rules-engine/suggestion-rules.ts` | NEW: Default suggestion rule definitions + evaluator function |
| `src/caller/caller-context.service.ts` | MODIFY: Add suggestion evaluation, render suggestions for prompt |
| `src/ai/ai-chat.controller.ts` | MODIFY: Inject suggestion rules into system prompt |
| `src/config/ai.defaults.ts` | MODIFY: Update ccAgentHelper prompt with structured JSON output format |
### Frontend (helix-engage)
| File | Responsibility |
|------|----------------|
| `src/components/call-desk/ai-summary-card.tsx` | NEW: Zone 1 patient profile card |
| `src/components/call-desk/ai-suggestions.tsx` | NEW: Zone 2 suggestion pills with expand/script/tell-me-more |
| `src/components/call-desk/ai-chat-panel.tsx` | REWRITE: Orchestrates 3 zones, parses structured JSON responses |
| `src/components/call-desk/context-panel.tsx` | MODIFY: Remove P360 tab toggle, single surface |
| `src/pages/rules-settings.tsx` | MODIFY: Display suggestion rules in Automations tab |
---
## Task 1: Suggestion Rules Engine (Sidecar)
**Files:**
- Create: `helix-engage-server/src/rules-engine/suggestion-rules.ts`
- Modify: `helix-engage-server/src/caller/caller-context.service.ts`
- [ ] **Step 1:** Create `suggestion-rules.ts` with types (`SuggestionType`, `SuggestionPriority`, `SuggestionTrigger`), department-to-package mapping, cross-sell mapping, and `evaluateSuggestionRules(facts)` function that evaluates 5 default rules: (1) package upsell by department, (2) reschedule missed appointments, (3) cross-sell related departments, (4) first-visit health checkup, (5) returning patient re-engagement. Max 4 triggers returned. Also export `SUGGESTION_RULE_DEFINITIONS` array for Settings UI display.
- [ ] **Step 2:** In `caller-context.service.ts`, add `suggestionTriggers: SuggestionTrigger[]` to the `CallerContext` type. Import `evaluateSuggestionRules`. Call it in the `build()` method after fetching all data, passing caller facts (isNew, appointments, calls, interestedService, contactAttempts, leadSource, utmCampaign). Add `renderSuggestionsForPrompt(triggers)` method that formats triggers for the AI system prompt.
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
- [ ] **Step 4:** Commit: `feat: suggestion rules engine + caller context evaluation`
---
## Task 2: Structured Output in AI System Prompt (Sidecar)
**Files:**
- Modify: `helix-engage-server/src/config/ai.defaults.ts`
- Modify: `helix-engage-server/src/ai/ai-chat.controller.ts`
- [ ] **Step 1:** In `ai.defaults.ts`, append structured output instructions to `CC_AGENT_HELPER_DEFAULT` template. The AI must respond with valid JSON: `{"message": "...", "suggestions": [{"id", "type", "title", "script", "priority"}]}`. Rules: always include suggestions on first response, update on subsequent, no markdown in message field, max 4 suggestions, personalized scripts using caller's name/doctor/department.
- [ ] **Step 2:** In `ai-chat.controller.ts` stream endpoint, after the caller context injection block, inject suggestion rules: `if (callerCtx.suggestionTriggers?.length) systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers)`
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
- [ ] **Step 4:** Commit: `feat: structured JSON output + suggestion rules in AI system prompt`
---
## Task 3: AI Summary Card Component (Frontend)
**Files:**
- Create: `helix-engage/src/components/call-desk/ai-summary-card.tsx`
- [ ] **Step 1:** Create Zone 1 component. Props: `caller: CallerSummary | null`. Renders: patient avatar + name + NEW/RETURNING badge, phone number, 2-line AI summary (line-clamped), source + campaign badges, compact appointment pills (next upcoming with green bg, last completed with gray bg). For null caller: centered placeholder text. Uses Badge component, FontAwesome icons (faUser, faCalendarCheck, faPhone).
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: AI summary card component (Zone 1)`
---
## Task 4: Suggestions Component (Frontend)
**Files:**
- Create: `helix-engage/src/components/call-desk/ai-suggestions.tsx`
- [ ] **Step 1:** Create Zone 2 component. Props: `suggestions: Suggestion[]`, `onTellMeMore: (suggestion) => void`. Exports `Suggestion` type (id, type, title, script, priority). Renders: collapsible section header "Suggestions (N)", list of compact pill cards. Each pill: type icon (faArrowUp/faTag/faRotate/faClipboardCheck), title, priority dot (red/yellow/green). Click toggles expand with script text + "Tell me more" link. Collapse/expand toggle for entire section.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: AI suggestions component (Zone 2)`
---
## Task 5: Rewrite AI Chat Panel (Frontend)
**Files:**
- Rewrite: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
- [ ] **Step 1:** Rewrite to orchestrate 3 zones. New props: `callerSummary?: CallerSummary | null`. Adds `suggestions` state managed from parsed AI responses. `parseAiResponse(content)` extracts `{ message, suggestions }` from JSON, falls back to raw text on parse failure. Zone 1: AiSummaryCard (not shown for supervisor). Zone 2: AiSuggestions with `onTellMeMore` that appends "Tell me more about X" as chat message. Zone 3: chat with `displayMessages` that strips JSON wrapper showing only the message field. Auto-fire kept. Supervisor mode unchanged (quick actions, no summary/suggestions). Keep existing MessageContent + parseLine helpers.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: rewrite AI chat panel — 3-zone coaching surface`
---
## Task 6: Wire Context Panel (Frontend)
**Files:**
- Modify: `helix-engage/src/components/call-desk/context-panel.tsx`
- [ ] **Step 1:** Remove P360 tab toggle (activeTab state, tab buttons, P360 sections — appointments list, call history list, follow-ups list). Build `callerSummary` object from `selectedLead` + `appointments` data: name, phone, isNew, aiSummary, leadSource, utmCampaign, nextAppointment (first SCHEDULED after now), lastAppointment (first COMPLETED). Pass `callerSummary` to AiChatPanel as new prop. Single surface — AiChatPanel is the only content.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: remove P360 toggle, single coaching surface`
---
## Task 7: Settings UI — Suggestion Rules Display
**Files:**
- Modify: `helix-engage/src/pages/rules-settings.tsx`
- [ ] **Step 1:** Add `SUGGESTION_RULES` array (5 items: name, category, description, enabled) to the Automations tab. Render below existing automation rules with "AI Suggestions" subheading. Same card pattern: category badge, name, description, enabled/disabled dot. All enabled, read-only.
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
- [ ] **Step 3:** Commit: `feat: display suggestion rules in Settings > Automations`
---
## Task 8: Build, Deploy, Test
- [ ] **Step 1:** Build sidecar: `cd helix-engage-server && npm run build`
- [ ] **Step 2:** Build frontend: `cd helix-engage && npm run build`
- [ ] **Step 3:** Deploy sidecar to ECR + pull on EC2
- [ ] **Step 4:** Deploy frontend to EC2 via rsync + restart Caddy
- [ ] **Step 5:** Test on Tauri: rebuild frontend with Global URL, launch, trigger call. Verify: Zone 1 summary card, Zone 2 suggestions from rules, click expand shows script, "Tell me more" sends to chat, progressive suggestion updates, server logs show cache hits and no tool calls for patient data
- [ ] **Step 6:** Final commit and push both repos

View File

@@ -0,0 +1,188 @@
# AI Coaching Panel — Design Spec
## Goal
Replace the current AI chat panel with a proactive coaching surface that shows structured patient summaries, rule-driven upsell/cross-sell/retention suggestions with clickable scripts, and a contextual chat — all in the existing 400px right-hand panel.
## Architecture
Single scrollable panel, three zones. No tabs or toggles. Caller context pre-fetched and cached in Redis (CallerContextService). Rules engine produces suggestion triggers. AI renders triggers into natural language scripts. Every AI response includes updated suggestions (progressive).
## Panel Layout
### Zone 1 — Summary Card (pinned top, ~120px)
- Patient name, age, gender, patient type badge (NEW / RETURNING)
- 2-line AI summary (from `aiSummary` field on lead record)
- Campaign badge + source tag (e.g., "Cervical Cancer Screening Drive" / "Google")
- Compact appointment pills: next upcoming appointment (date + doctor), last completed (date + outcome)
- Renders from CallerContextService data — no AI call needed for this zone
For new callers (no lead/patient): shows phone number, "New Caller" badge, and a prompt to collect name.
### Zone 2 — Suggestions (collapsible, below summary)
- 2-4 suggestion pills as compact cards
- Each pill: type icon (tag/arrow-up/rotate-cw), one-line title, priority dot (high/medium/low)
- Click expands inline with a 2-3 sentence ready-to-read script
- Expanded state has a "Tell me more" link that sends the suggestion as a chat message
- Suggestions refresh with every AI response (progressive)
- Collapse/expand toggle for the entire section ("Suggestions (3)")
Suggestion types:
- **upsell** — premium packages, add-on services
- **crosssell** — related services in other departments
- **retention** — reschedule missed appointments, follow up on lapsed visits
- **operational** — fasting reminders, insurance docs, directions
### Zone 3 — Chat (fills remaining space)
- Streaming chat, same UX as today
- Agent types questions or clicks "Tell me more" from a suggestion
- Each AI response may include updated suggestions (Zone 2 refreshes)
- Quick action pills at bottom, contextual to conversation state
- Auto-fires patient summary on call connect (existing behavior, kept)
## Structured AI Response Format
Every AI response is structured JSON (not free-form text):
```json
{
"message": "Priya Sharma is a returning patient...",
"suggestions": [
{
"id": "s1",
"type": "upsell",
"title": "Cardiac Wellness Package",
"script": "Since you're already seeing Dr. Lakshmi for cardiology, we have a comprehensive cardiac wellness package...",
"priority": "high"
},
{
"id": "s2",
"type": "retention",
"title": "Reschedule missed appointment",
"script": "I see your last appointment on April 10th was rescheduled. Would you like me to book a new slot?",
"priority": "medium"
}
]
}
```
The `message` field renders as a chat bubble in Zone 3. The `suggestions` array replaces the current set in Zone 2. If `suggestions` is empty or absent, Zone 2 retains the previous set.
The initial auto-fired response includes the summary message + first set of suggestions. Subsequent responses update suggestions based on conversation context.
## Rules Engine to AI Prompt Pipeline
### Step 1: Rules evaluation
CallerContextService already builds the caller facts (appointments, campaigns, call history, lead status, interested service). The rules engine evaluates these facts against configured suggestion rules.
Each rule produces a raw trigger:
```json
{
"type": "upsell",
"product": "cardiac-wellness-package",
"reason": "Patient has cardiology appointment, no wellness package booked",
"priority": "high"
}
```
### Step 2: Prompt injection
Raw triggers are appended to the system prompt as a `SUGGESTION RULES` section:
```
SUGGESTION RULES (from business configuration):
Based on this caller's profile, the following suggestions should be offered.
Generate a natural, conversational script for each that the agent can read aloud.
Return them in the `suggestions` array of your JSON response.
1. [upsell/high] Cardiac Wellness Package — patient has cardiology appointment, no wellness package booked
2. [retention/medium] Reschedule missed appointment — last appointment was rescheduled, no new booking
```
### Step 3: AI generates scripts
The AI turns the raw triggers into conversational scripts using the caller's context (name, history, doctor, department). Scripts are personalized, not templated.
### Step 4: Seeded rules
Default suggestion rules seeded in the rules engine config:
- Package upsell by department (cardiology → cardiac wellness, ortho → physio package)
- Reschedule missed/cancelled appointments
- Cross-sell related departments (ortho → physio, cardio → dietician)
- First-visit patient: suggest health checkup package
- Returning patient with no recent visit: re-engagement prompt
These rules are displayed read-only in Settings > Automations tab (same card pattern as existing automation rules — visible but not editable in v1).
## Data Flow
```
Call arrives
-> CallerResolutionController.resolve()
-> CallerContextService.prewarm() (parallel fetch + Redis cache)
-> Frontend auto-fires AI chat
-> POST /api/ai/stream
-> buildCallerContext() — Redis cache hit
-> rulesEngine.evaluate(callerFacts) — produces suggestion triggers
-> buildSystemPrompt(KB + callerContext + suggestionRules + structuredOutputInstructions)
-> streamText() — AI returns structured JSON { message, suggestions }
-> Frontend parses response
-> Zone 1: summary card from CallerContextService (no AI needed)
-> Zone 2: suggestions from AI response
-> Zone 3: message as chat bubble
Agent clicks "Tell me more" on a suggestion
-> Sent as chat message: "Tell me more about the Cardiac Wellness Package"
-> AI responds with detailed info + updated suggestions
-> Zone 2 refreshes with new suggestions
Agent books appointment (via disposition/form)
-> System message injected into chat: "Agent booked appointment with Dr. Lakshmi on Apr 24"
-> Next AI response reflects the action + updates suggestions
(e.g., removes "reschedule" suggestion, adds "send appointment reminder via WhatsApp")
```
## Surface Area
### Sidecar (helix-engage-server)
| File | Change |
|------|--------|
| `ai-chat.controller.ts` | Add structured output instructions to system prompt. Add suggestion rules injection from rules engine. Parse/pass suggestion triggers. |
| `caller-context.service.ts` | Add rules evaluation method that runs caller facts against suggestion rules. Return triggers alongside context. |
| `rules-engine/` | Seed default suggestion rules (JSON config in Redis or file). |
| `config/ai.defaults.ts` | Update `ccAgentHelper` prompt template with structured output format instructions and suggestion generation rules. |
### Frontend (helix-engage)
| File | Change |
|------|--------|
| NEW: `ai-summary-card.tsx` | Zone 1 — patient profile card rendered from CallerContextService data |
| NEW: `ai-suggestions.tsx` | Zone 2 — suggestion pills with expand/collapse, script display, "Tell me more" |
| REWRITE: `ai-chat-panel.tsx` | Orchestrates all 3 zones. Parses structured JSON responses. Manages suggestion state. Passes "Tell me more" clicks as chat messages. |
| `context-panel.tsx` | Remove P360 tab toggle. Single surface — AI coaching panel is the only mode. |
### No changes needed
- `call-desk.tsx` — panel wrapper stays the same
- `app-shell.tsx` — no changes
- `CallerContextService` — already built, just add rules evaluation call
- Frontend build pipeline — no new dependencies
## What this replaces
- P360 context tab (appointments, call history, follow-ups tables) — replaced by AI summary card
- AI chat toggle — removed (single surface)
- Tool-based patient lookups during chat — replaced by pre-fetched context in KB
- Static quick action pills — replaced by rule-driven contextual suggestions
## Out of scope for v1
- Editable suggestion rules UI (shown read-only in Settings)
- Supervisor AI coaching (different tool set, different panel)
- Real-time transcript-driven suggestions (requires live call transcription)
- Suggestion analytics (which suggestions agents click, conversion tracking)

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,13 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faSparkles, faPhone, faChevronDown, faChevronUp,
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
} from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from './ai-chat-panel'; import { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges'; import type { Appointment } from '@/types/entities';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
// The context panel can render for any worklist item — not just leads.
// Missed calls and follow-ups provide a subset of the fields (phone +
// patientId + name) without a full Lead entity. ContextPanelSubject
// captures the minimum the panel needs to render P360.
export type ContextPanelSubject = { export type ContextPanelSubject = {
id: string; id: string;
contactName?: { firstName: string; lastName: string } | null; contactName?: { firstName: string; lastName: string } | null;
contactPhone?: Array<{ number: string; callingCode: string }> | null; contactPhone?: Array<{ number: string; callingCode: string }> | null;
patientId?: string | null; patientId?: string | null;
// Lead-specific fields — present when the subject IS a lead
leadSource?: string | null; leadSource?: string | null;
leadStatus?: string | null; leadStatus?: string | null;
aiSummary?: string | null; aiSummary?: string | null;
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
interface ContextPanelProps { interface ContextPanelProps {
selectedLead: ContextPanelSubject | null; selectedLead: ContextPanelSubject | null;
activities: LeadActivity[]; activities: any[];
calls: Call[]; calls: any[];
followUps: FollowUp[]; followUps: any[];
appointments: Appointment[]; appointments: Appointment[];
patients: Patient[]; patients: any[];
callerPhone?: string; callerPhone?: string;
isInCall?: boolean; isInCall?: boolean;
callUcid?: string | null; callUcid?: string | null;
} }
const formatTimeAgo = (dateStr: string): string => { export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const formatDuration = (sec: number): string => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
}) => (
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
>
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
{count !== undefined && count > 0 && (
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
)}
<FontAwesomeIcon
icon={expanded ? faChevronUp : faChevronDown}
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
/>
</button>
);
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const navigate = useNavigate();
const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true);
const [recentExpanded, setRecentExpanded] = useState(true);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null); const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const lead = selectedLead; const lead = selectedLead;
@@ -96,21 +43,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
leadName: fullName, leadName: fullName,
} : callerPhone ? { callerPhone } : undefined; } : callerPhone ? { callerPhone } : undefined;
// Filter data for this lead
const leadCalls = useMemo(() =>
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[calls, lead, callerPhone],
);
const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3),
[followUps, lead],
);
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = lead?.patientId; const patientId = lead?.patientId;
if (!patientId) return []; if (!patientId) return [];
@@ -120,29 +52,9 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
.slice(0, 3); .slice(0, 3);
}, [appointments, lead]); }, [appointments, lead]);
const leadActivities = useMemo(() => const handleChatStart = useCallback(() => {}, []);
activities.filter(a => a.leadId === lead?.id)
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
.slice(0, 5),
[activities, lead],
);
// Linked patient // Edit mode takes over the whole right panel
const linkedPatient = useMemo(() =>
patients.find(p => p.id === lead?.patientId),
[patients, lead],
);
// Auto-collapse context sections when chat starts
const handleChatStart = useCallback(() => {
setContextExpanded(false);
}, []);
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
// Edit mode takes over the whole right panel — otherwise the
// AppointmentForm competes with the AI panel + context blocks for
// vertical space and gets crushed into a tiny strip at the bottom.
if (editingAppointment) { if (editingAppointment) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -178,199 +90,28 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
); );
} }
// Build callerSummary for the AI coaching panel
const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date());
const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED');
const callerSummary = lead ? {
name: fullName,
phone: phone?.number ?? callerPhone ?? '',
isNew: false,
aiSummary: (lead as any).aiSummary ?? null,
leadSource: (lead as any).leadSource ?? null,
utmCampaign: (lead as any).utmCampaign ?? null,
nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null,
lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null,
} : callerPhone ? {
name: '',
phone: callerPhone,
isNew: true,
} : null;
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Lead header — always visible */}
{lead && (
<div className="shrink-0 border-b border-secondary">
<button
onClick={() => setContextExpanded(!contextExpanded)}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
>
{isInCall && (
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
)}
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
{phone && (
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
)}
{lead.leadStatus && (
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
)}
<FontAwesomeIcon
icon={contextExpanded ? faChevronUp : faChevronDown}
className="size-3 text-fg-quaternary ml-auto shrink-0"
/>
</button>
{/* Expanded context sections */}
{contextExpanded && (
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
{/* AI Insight */}
{lead.aiSummary && (
<div>
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
{insightExpanded && (
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
{lead.aiSuggestedAction && (
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
)}
</div>
)}
</div>
)}
{/* Campaign info */}
{(lead.utmCampaign || lead.campaignId) && (
<div className="flex items-center gap-1.5 px-1 py-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
<Badge size="sm" color="brand" type="pill-color">
{lead.utmCampaign ?? lead.campaignId}
</Badge>
</div>
)}
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
<div>
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
{actionsExpanded && (
<div className="space-y-1 mb-1">
{leadAppointments.map(appt => (
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{appt.doctorName ?? 'Appointment'}
</span>
<span className="text-[11px] text-tertiary ml-1">
{appt.department}
</span>
{appt.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1">
{formatShortDate(appt.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
</Badge>
<button
onClick={() => setEditingAppointment(appt)}
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
>
Edit
</button>
</div>
))}
{leadFollowUps.map(fu => (
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
</span>
{fu.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1.5">
{formatShortDate(fu.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
</Badge>
</div>
))}
{linkedPatient && (
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
<span className="text-xs text-primary">
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
</span>
{linkedPatient.patientType && (
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
)}
<button
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
>
View 360
</button>
</div>
)}
</div>
)}
</div>
)}
{/* Recent calls + activities */}
{(leadCalls.length > 0 || leadActivities.length > 0) && (
<div>
<SectionHeader
icon={faClockRotateLeft}
label="Recent"
count={leadCalls.length + leadActivities.length}
expanded={recentExpanded}
onToggle={() => setRecentExpanded(!recentExpanded)}
/>
{recentExpanded && (
<div className="space-y-0.5 mb-1">
{leadCalls.map(call => (
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
<FontAwesomeIcon
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
className={cx('size-3 shrink-0',
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
)}
/>
<div className="min-w-0 flex-1">
<span className="text-xs text-primary">
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
</span>
{call.durationSeconds != null && call.durationSeconds > 0 && (
<span className="text-[11px] text-tertiary ml-1"> {formatDuration(call.durationSeconds)}</span>
)}
{call.disposition && (
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
)}
</div>
<span className="text-[11px] text-quaternary shrink-0">
{formatTimeAgo(call.startedAt ?? call.createdAt)}
</span>
</div>
))}
{leadActivities
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
.slice(0, 3)
.map(a => (
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
{a.occurredAt && (
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
)}
</div>
))
}
</div>
)}
</div>
)}
{/* No context available */}
{!hasContext && (
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
)}
</div>
)}
</div>
)}
{/* AI Chat — fills remaining space */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} /> <AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components'; import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell'; import { PhoneActionCell } from './phone-action-cell';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format'; import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -79,6 +75,9 @@ interface WorklistPanelProps {
onSelectItem: (selection: WorklistSelection) => void; onSelectItem: (selection: WorklistSelection) => void;
selectedItemId: string | null; selectedItemId: string | null;
onDialMissedCall?: (missedCallId: string) => void; onDialMissedCall?: (missedCallId: string) => void;
// Lifted from internal state — owned by call-desk.tsx so the search
// input can live in the PageHeader row alongside other controls.
search: string;
} }
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
@@ -299,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
return actionableRows; return actionableRows;
}; };
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
// sub-tabs were removed per QA feedback — pending callbacks are the only // sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist. // ones agents need to act on from the worklist.
@@ -402,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Reset page when search changes from parent
useEffect(() => { setPage(1); }, [search]);
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -436,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */} {/* Filter pills — custom buttons matching All Leads pattern */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5"> <div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}> {tabItems.map((item) => (
<TabList items={tabItems} type="underline" size="sm"> <button
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} key={item.id}
</TabList> onClick={() => handleTabChange(item.id)}
</Tabs> className={cx(
<div className="w-44 shrink-0"> 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
<Input tab === item.id
placeholder="Search..." ? 'bg-brand-solid text-white'
icon={SearchLg} : 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
size="sm" )}
value={search} >
onChange={handleSearch} {item.label}{item.badge ? ` (${item.badge})` : ''}
aria-label="Search worklist" </button>
/> ))}
</div>
</div> </div>
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab {/* Missed-call sub-tabs removed per QA feedback — the Missed tab

View File

@@ -8,7 +8,6 @@ import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget'; import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner'; import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -25,7 +24,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => { export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isCCAgent, isAdmin } = useAuth(); const { isCCAgent } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts(); const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip(); const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus(); const networkQuality = useNetworkStatus();
@@ -118,35 +117,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>
)} )}

View File

@@ -0,0 +1,102 @@
// PageHeader — consistent header layout for all list pages.
//
// Row 1: Title (+ optional badge + info icon) on the left,
// controls (search, columns, export, etc.) on the right.
// Row 2: Optional tabs (underline style) — no extra borders.
//
// The `infoText` prop renders as a hoverable info icon (ⓘ) next to
// the title. Long descriptive text goes here instead of inline
// subtitle — keeps the header compact.
//
// Usage:
// <PageHeader
// title="Contacts"
// badge={16}
// infoText="People who reached out directly — phone, walk-in, referral."
// controls={<><Input .../> <Button .../></>}
// tabs={<Tabs ...><TabList ...>{...}</TabList></Tabs>}
// />
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { NotificationBell } from './notification-bell';
import { useAuth } from '@/providers/auth-provider';
interface PageHeaderProps {
title: string;
badge?: number | string;
/** Short inline text next to badge — use sparingly (e.g. "17 total") */
subtitle?: string;
/** Longer descriptive text shown on info icon hover/click */
infoText?: string;
controls?: ReactNode;
tabs?: ReactNode;
}
const InfoTooltip = ({ text }: { text: string }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
className="flex size-5 items-center justify-center rounded-full text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
title={text}
>
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-72 rounded-lg bg-primary px-3 py-2 text-xs text-tertiary shadow-lg ring-1 ring-secondary">
{text}
</div>
)}
</div>
);
};
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
const { isAdmin } = useAuth();
return (
<div className="shrink-0">
{/* Row 1: title + controls */}
<div className="flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">{title}</h1>
{badge != null && (
<span className="inline-flex items-center justify-center rounded-full bg-brand-secondary px-2 py-0.5 text-xs font-semibold text-white">
{badge}
</span>
)}
{subtitle && (
<span className="text-sm text-tertiary ml-1">{subtitle}</span>
)}
{infoText && <InfoTooltip text={infoText} />}
</div>
<div className="flex items-center gap-2">
{controls}
{isAdmin && <NotificationBell />}
</div>
</div>
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
{tabs && (
<div className="px-6">
{tabs}
</div>
)}
</div>
);
};

View File

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

View File

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

View File

@@ -11,8 +11,7 @@ import { Input } from '@/components/base/input/input';
// Tabs removed — campaign pills handle all filtering now // Tabs removed — campaign pills handle all filtering now
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; // import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header import { PageHeader } from '@/components/layout/page-header';
// import { TopBar } from '@/components/layout/top-bar';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
// Bulk actions removed — checkboxes hidden // Bulk actions removed — checkboxes hidden
@@ -244,37 +243,37 @@ export const AllLeadsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header with controls inline */} <PageHeader
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> title="All Leads"
<div> subtitle={`${total} total`}
<h1 className="text-lg font-bold text-primary">All Leads</h1> infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
<p className="text-xs text-tertiary">{total} total</p> controls={
</div> <>
<div className="flex items-center gap-2"> <div className="w-56">
<div className="w-56"> <Input
<Input placeholder="Search leads..."
placeholder="Search leads..." icon={SearchLg}
icon={SearchLg} size="sm"
value={searchQuery}
onChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
}}
aria-label="Search leads"
/>
</div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button
size="sm" size="sm"
value={searchQuery} color="secondary"
onChange={(value) => { iconLeading={Download01}
setSearchQuery(value); onClick={handleExportCsv}
setCurrentPage(1); >
}} Export CSV
aria-label="Search leads" </Button>
/> </>
</div> }
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> />
<Button
size="sm"
color="secondary"
iconLeading={Download01}
onClick={handleExportCsv}
>
Export CSV
</Button>
</div>
</div>
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">

View File

@@ -12,7 +12,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';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
// TopBar replaced by inline header // 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';
@@ -20,6 +19,7 @@ import { Select } from '@/components/base/select/select';
import { DatePicker } from '@/components/application/date-picker/date-picker'; import { DatePicker } from '@/components/application/date-picker/date-picker';
import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; 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 { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -551,33 +551,44 @@ export const AppointmentsPageV2 = () => {
return ( return (
<> <>
{/* Header with search inline */} <PageHeader
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> title="Appointments"
<div> badge={filtered.length}
<h1 className="text-lg font-bold text-primary">Appointments</h1> infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
</div> controls={
<div className="w-56"> <div className="w-56">
<Input <Input
placeholder="Search patient, doctor..." placeholder="Search patient, doctor..."
icon={SearchLg} icon={SearchLg}
size="sm" size="sm"
value={search} value={search}
onChange={setSearch} onChange={setSearch}
aria-label="Search appointments" aria-label="Search appointments"
/> />
</div> </div>
</div> }
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</div>
}
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs */}
<div className="flex shrink-0 items-end px-6 pt-2 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
</div>
<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 ? (
@@ -610,7 +621,7 @@ export const AppointmentsPageV2 = () => {
return ( return (
<Table.Row <Table.Row
id={appt.id} id={appt.id}
className={cx(isSelected && 'bg-brand-primary')} className={cx('group/row', isSelected && 'bg-brand-primary')}
> >
{/* Eye icon — first column */} {/* Eye icon — first column */}
<Table.Cell> <Table.Cell>
@@ -627,7 +638,7 @@ export const AppointmentsPageV2 = () => {
<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 && <p className="text-xs text-tertiary">{formatPhone({ number: phone, callingCode: '+91' })}</p>} {phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div> </div>
</Table.Cell> </Table.Cell>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -12,11 +12,15 @@ import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel'; import { ContextPanel } from '@/components/call-desk/context-panel';
import type { ContextPanelSubject } from '@/components/call-desk/context-panel'; import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Input } from '@/components/base/input/input';
import { faIcon } from '@/lib/icon-wrapper';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const SearchLg = faIcon(faMagnifyingGlass);
export const CallDeskPage = () => { export const CallDeskPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
@@ -30,6 +34,7 @@ export const CallDeskPage = () => {
const [diallerOpen, setDiallerOpen] = useState(false); const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState(''); const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false); const [dialling, setDialling] = useState(false);
const [search, setSearch] = useState('');
// DEV: simulate incoming call // DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom); const setSimCallState = useSetAtom(sipCallStateAtom);
@@ -166,14 +171,29 @@ export const CallDeskPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */} {/* Header — matches PageHeader visual pattern */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> <div className="flex shrink-0 items-center justify-between px-6 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">Call Desk</h1> <h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span> <span className="text-sm text-tertiary ml-1">{user.name}</span>
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isInCall && (
<div className="w-52">
<Input
placeholder="Search worklist..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search worklist"
/>
</div>
)}
{import.meta.env.DEV && (!isInCall ? ( {import.meta.env.DEV && (!isInCall ? (
<button <button
onClick={startSimCall} onClick={startSimCall}
@@ -283,6 +303,7 @@ export const CallDeskPage = () => {
onSelectItem={handleSelectItem} onSelectItem={handleSelectItem}
selectedItemId={selectedItemId} selectedItemId={selectedItemId}
onDialMissedCall={(id) => setActiveMissedCallId(id)} onDialMissedCall={(id) => setActiveMissedCallId(id)}
search={search}
/> />
)} )}
</div> </div>

View File

@@ -11,11 +11,12 @@ import {
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { PageHeader } from '@/components/layout/page-header';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatShortDate, formatPhone } from '@/lib/format'; import { formatShortDate, formatPhone } from '@/lib/format';
// cx removed — no longer used after SLA column removal // cx removed — no longer used after SLA column removal
@@ -189,44 +190,44 @@ export const CallHistoryPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden p-7"> <PageHeader
<TableCard.Root size="md" className="flex-1 min-h-0"> title={isAdmin ? 'Call History' : 'My Call History'}
<TableCard.Header badge={filteredCalls.length}
title={isAdmin ? 'Call History' : 'My Call History'} subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
badge={String(filteredCalls.length)} infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
description={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`} controls={
contentTrailing={ <>
<div className="flex items-center gap-2"> <div className="w-44">
<div className="w-44"> <Select
<Select size="sm"
size="sm" placeholder="All Calls"
placeholder="All Calls" selectedKey={filter}
selectedKey={filter} onSelectionChange={(key) => setFilter(key as FilterKey)}
onSelectionChange={(key) => setFilter(key as FilterKey)} items={isAdmin ? allFilterItems : agentFilterItems}
items={isAdmin ? allFilterItems : agentFilterItems} aria-label="Filter calls"
aria-label="Filter calls" >
> {(item) => (
{(item) => ( <Select.Item id={item.id} label={item.label}>
<Select.Item id={item.id} label={item.label}> {item.label}
{item.label} </Select.Item>
</Select.Item> )}
)} </Select>
</Select>
</div>
<div className="w-56">
<Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
</div> </div>
} <div className="w-56">
/> <Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
</>
}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{filteredCalls.length === 0 ? ( {filteredCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16"> <div className="flex flex-col items-center justify-center py-16">
<h3 className="text-sm font-semibold text-primary">No calls found</h3> <h3 className="text-sm font-semibold text-primary">No calls found</h3>
@@ -314,15 +315,16 @@ export const CallHistoryPage = () => {
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</TableCard.Root>
</div> </div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
)}
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,8 @@ const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -125,14 +124,15 @@ const renderCell = (call: MissedCallRecord, colId: string) => {
} }
}; };
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: { const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[]; calls: MissedCallRecord[];
columns: ColDef[]; columns: ColDef[];
columnKey: string;
sortDescriptor: SortDescriptor; sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void; onSortChange: (desc: SortDescriptor) => void;
}) => ( }) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}> <Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}> <Table.Header columns={columns}>
{(col) => ( {(col) => (
<Table.Head <Table.Head
@@ -146,7 +146,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
</Table.Header> </Table.Header>
<Table.Body items={calls}> <Table.Body items={calls}>
{(call) => ( {(call) => (
<Table.Row id={call.id} columns={columns}> <Table.Row id={call.id} columns={columns} className="group/row">
{(col) => ( {(col) => (
<Table.Cell key={col.id}> <Table.Cell key={col.id}>
{renderCell(call, col.id)} {renderCell(call, col.id)}
@@ -238,55 +238,70 @@ export const MissedCallsPage = () => {
]; ];
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Missed Calls" /> <PageHeader
<div className="flex flex-1 flex-col overflow-hidden"> title="Missed Calls"
{/* Tabs + toolbar */} badge={calls.length}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5"> infoText="Inbound calls that were not answered. Agents can call back from here."
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> controls={
<TabList items={tabItems} type="underline" size="sm"> <>
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="flex items-center gap-3 pb-1">
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56"> <div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> <Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div> </div>
</>
}
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => handleTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</div> </div>
</div> }
/>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p> <p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div> </div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>
)} )}
</div> </div>
</>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
); );
}; };

View File

@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
// useNavigate removed — row click opens profile panel // useNavigate removed — row click opens profile panel
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faCommentDots, faMessageDots, faEllipsisVertical, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PageHeader } from '@/components/layout/page-header';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
@@ -54,49 +55,6 @@ const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? ''; return patient.emails?.primaryEmail ?? '';
}; };
const HamburgerMenu = ({ phone }: { phone: string }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
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="More actions"
>
<FontAwesomeIcon icon={faEllipsisVertical} className="size-4" />
</button>
{open && (
<div className="absolute top-full right-0 mt-1 w-40 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden py-1">
<button
onClick={(e) => { e.stopPropagation(); window.open(`sms:+91${phone}`, '_self'); setOpen(false); }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
>
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-quaternary" />
Send SMS
</button>
<button
onClick={(e) => { e.stopPropagation(); window.open(`https://wa.me/91${phone}`, '_blank'); setOpen(false); }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
>
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-fg-quaternary" />
WhatsApp
</button>
</div>
)}
</div>
);
};
export const PatientsPage = () => { export const PatientsPage = () => {
const { patients, loading } = useData(); const { patients, loading } = useData();
@@ -127,37 +85,36 @@ export const PatientsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="All Patients"
badge={filteredPatients.length}
infoText="Manage and view patient records"
controls={
<>
<button
onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</>
}
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
<TableCard.Root size="sm">
<TableCard.Header
title="All Patients"
badge={filteredPatients.length}
description="Manage and view patient records"
contentTrailing={
<div className="flex items-center gap-2">
<button
onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</div>
}
/>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p> <p className="text-sm text-tertiary">Loading patients...</p>
@@ -178,7 +135,6 @@ export const PatientsPage = () => {
<Table.Head label="EMAIL" /> <Table.Head label="EMAIL" />
<Table.Head label="GENDER" /> <Table.Head label="GENDER" />
<Table.Head label="AGE" /> <Table.Head label="AGE" />
<Table.Head label="" className="w-12" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
@@ -195,7 +151,7 @@ export const PatientsPage = () => {
<Table.Row <Table.Row
id={patient.id} id={patient.id}
className={cx( className={cx(
'cursor-pointer', 'cursor-pointer group/row',
selectedPatient?.id === patient.id && 'bg-brand-primary' selectedPatient?.id === patient.id && 'bg-brand-primary'
)} )}
onAction={() => { onAction={() => {
@@ -255,25 +211,18 @@ export const PatientsPage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
{/* Hamburger — SMS + WhatsApp */}
<Table.Cell>
{phone ? (
<HamburgerMenu phone={phone} />
) : null}
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
</TableCard.Root>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3"> <div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} /> <PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div> </div>
)} )}
</div> </div>
{/* Patient Profile Panel - collapsible with smooth transition */} {/* Patient Profile Panel - collapsible with smooth transition */}

View File

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

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PageHeader } from '@/components/layout/page-header';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; 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';
@@ -55,36 +56,36 @@ 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 key={range}
key={range} onClick={() => setDateRange(range)}
onClick={() => setDateRange(range)} className={cx(
className={cx( "px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear", dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover', )}
)} >
> {range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'} </button>
</button> ))}
))} </div>
</div> <button
<button onClick={() => setAiOpen(!aiOpen)}
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"
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'}
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'} >
> <FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" /> </button>
</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

View File

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