mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: build all data pages — worklist table, call history, patients, dashboard, reports
Worklist (call-desk): - Upgrade to Untitled UI Table with columns: Priority, Patient, Phone, Type, SLA, Actions - Filter tabs: All Tasks / Missed Calls / Callbacks / Follow-ups with counts - Search by name or phone - SLA timer color-coded: green <15m, amber <30m, red >30m Call History: - Full table: Type (direction icon), Patient (matched from leads), Phone, Duration, Outcome, Agent, Recording (play/pause), Time - Search + All/Inbound/Outbound/Missed filter - Recording playback via native <audio> Patients: - New page with table: Patient (avatar+name+age), Contact, Type, Gender, Status, Actions - Search + status filter - Call + View Details actions - Added patients to DataProvider + transforms + queries - Route /patients added, sidebar nav updated for cc-agent + executive Supervisor Dashboard: - KPI cards: Total Calls, Inbound, Outbound, Missed - Performance metrics: Avg Response Time, Callback Time, Conversion % - Agent performance table with per-agent stats - Missed Call Queue - AI Assistant section - Day/Week/Month filter Reports: - ECharts bar chart: Call Volume Trend (7-day, Inbound vs Outbound) - ECharts donut chart: Call Outcomes (Booked, Follow-up, Info, Missed) - KPI cards with trend indicators (+/-%) - Route /reports, sidebar Analytics → /reports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
341
docs/superpowers/plans/2026-03-19-data-pages.md
Normal file
341
docs/superpowers/plans/2026-03-19-data-pages.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Data Pages — Table Views, Dashboard, Reports
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build all missing data presentation pages using Untitled UI Table component and Apache ECharts. Transform card-based views into proper sortable/filterable tables. Add supervisor dashboard with KPIs and reports with charts.
|
||||||
|
|
||||||
|
**Architecture:** All data comes from the platform via the DataProvider (already wired). Pages use the Untitled UI `Table` and `TableCard` components for consistent table presentation. Charts use `echarts-for-react`. Each page is a separate route — no shared state beyond DataProvider.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Untitled UI Table (`src/components/application/table/table.tsx`), Apache ECharts (`echarts-for-react`), Jotai, existing DataProvider + apiClient pattern
|
||||||
|
|
||||||
|
**Deployed at:** https://engage.srv1477139.hstgr.cloud
|
||||||
|
**Sidecar:** https://engage-api.srv1477139.hstgr.cloud
|
||||||
|
**Platform:** https://fortytwo-dev.srv1477139.hstgr.cloud
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Context
|
||||||
|
|
||||||
|
### Untitled UI Table API
|
||||||
|
```tsx
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
|
import { TableBody } from 'react-aria-components';
|
||||||
|
|
||||||
|
<TableCard.Root size="sm">
|
||||||
|
<TableCard.Header title="Worklist" badge={count} description="..." contentTrailing={<Button>...</Button>} />
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="PATIENT" />
|
||||||
|
<Table.Head label="PHONE" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={data}>
|
||||||
|
{(item) => (
|
||||||
|
<Table.Row id={item.id}>
|
||||||
|
<Table.Cell>{item.name}</Table.Cell>
|
||||||
|
<Table.Cell>{item.phone}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</TableCard.Root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Sources (all from DataProvider)
|
||||||
|
- `leads` — Lead[] with contactName, contactPhone, status, source, aiSummary, etc.
|
||||||
|
- `calls` — Call[] with direction, callStatus, durationSeconds, agentName, disposition, startedAt
|
||||||
|
- `campaigns` — Campaign[] with metrics (impressions, clicks, converted, budget)
|
||||||
|
- `followUps` — FollowUp[] with type, status, scheduledAt, priority
|
||||||
|
- `leadActivities` — LeadActivity[] with activityType, summary, occurredAt
|
||||||
|
- `ads` — Ad[] linked to campaigns
|
||||||
|
|
||||||
|
### Existing Pages (routes in main.tsx)
|
||||||
|
- `/` — RoleRouter (redirects based on role)
|
||||||
|
- `/leads` — AllLeadsPage
|
||||||
|
- `/campaigns` — CampaignsPage
|
||||||
|
- `/campaigns/:id` — CampaignDetailPage
|
||||||
|
- `/outreach` — OutreachPage
|
||||||
|
- `/follow-ups` — FollowUpsPage
|
||||||
|
- `/call-history` — CallHistoryPage
|
||||||
|
- `/call-desk` — CallDeskPage (2-panel with worklist + AI)
|
||||||
|
- `/team-dashboard` — TeamDashboardPage
|
||||||
|
- `/patient/:id` — Patient360Page
|
||||||
|
|
||||||
|
### Sidebar Navigation (by role)
|
||||||
|
**admin:** Team Dashboard, Campaigns, Analytics, Integrations, Settings
|
||||||
|
**cc-agent:** Call Desk, Follow-ups, Call History
|
||||||
|
**executive:** Lead Workspace, All Leads, Campaigns, Outreach, Analytics
|
||||||
|
|
||||||
|
### API Pattern
|
||||||
|
```tsx
|
||||||
|
// All data fetched silently via DataProvider on mount
|
||||||
|
const { leads, calls, followUps, campaigns, leadActivities, loading } = useData();
|
||||||
|
// For sidecar-specific data:
|
||||||
|
const worklist = apiClient.get<WorklistData>('/api/worklist');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Platform Field Mapping (SDK → Platform)
|
||||||
|
- Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted, lastContactedAt→lastContacted
|
||||||
|
- Call: callDirection→direction, durationSeconds→durationSec
|
||||||
|
- Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions
|
||||||
|
- FollowUp: followUpType→typeCustom, followUpStatus→status
|
||||||
|
- Appointment: appointmentStatus→status, durationMinutes→durationMin
|
||||||
|
|
||||||
|
### Deploy Pattern
|
||||||
|
```bash
|
||||||
|
# Build frontend
|
||||||
|
cd helix-engage
|
||||||
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
|
||||||
|
VITE_SIP_PASSWORD=Test123$ \
|
||||||
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Upload to VPS
|
||||||
|
scp -r -i /Users/satyasumansaridae/Downloads/fortytwoai_hostinger \
|
||||||
|
dist/* root@148.230.67.184:/opt/fortytwo/helix-engage-frontend/
|
||||||
|
|
||||||
|
# Build + push sidecar (if sidecar changes)
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
docker build --platform linux/amd64 -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha .
|
||||||
|
docker save 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha | \
|
||||||
|
ssh -i /Users/satyasumansaridae/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker load"
|
||||||
|
ssh -i /Users/satyasumansaridae/Downloads/fortytwoai_hostinger root@148.230.67.184 \
|
||||||
|
"cd /opt/fortytwo && docker compose up -d --force-recreate sidecar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Worklist Table Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/call-desk.tsx` — keep the 2-panel layout but upgrade worklist to table
|
||||||
|
- Modify: `src/components/call-desk/worklist-panel.tsx` — replace card list with Table component
|
||||||
|
|
||||||
|
The call desk's left panel worklist should use the Table component. Columns:
|
||||||
|
- Priority (color badge: HIGH/MEDIUM/LOW/URGENT)
|
||||||
|
- Patient name + age (e.g. "Priya Sharma · 1h ago")
|
||||||
|
- Phone
|
||||||
|
- Direction (icon: inbound/outbound)
|
||||||
|
- Type (Missed Call / Callback / Follow-up / Lead)
|
||||||
|
- Reason (truncated summary)
|
||||||
|
- SLA timer (computed: time since created, color-coded green/amber/red)
|
||||||
|
- Task State (PENDING/ATTEMPTED/SCHEDULED)
|
||||||
|
- Actions (Call button + dismiss)
|
||||||
|
|
||||||
|
Filter tabs: All Tasks | Missed Calls | Callbacks | Follow-ups (with counts)
|
||||||
|
Filter toggle: All / Inbound / Outbound
|
||||||
|
Search: by patient name or phone
|
||||||
|
|
||||||
|
Data source: `useWorklist()` hook (sidecar `/api/worklist`)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Rewrite `worklist-panel.tsx` using `Table` + `TableCard` components
|
||||||
|
- [ ] **Step 2:** Add filter tabs and search
|
||||||
|
- [ ] **Step 3:** Add SLA timer computation (minutes since created, color thresholds: <15m green, <30m amber, >30m red)
|
||||||
|
- [ ] **Step 4:** Type check and verify
|
||||||
|
- [ ] **Step 5:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Call History Table Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `src/pages/call-history.tsx`
|
||||||
|
- Modify: `src/lib/queries.ts` — add CALLS query if not already fetching all fields
|
||||||
|
|
||||||
|
Full call history table. Columns:
|
||||||
|
- Type (inbound/outbound icon)
|
||||||
|
- Patient (matched lead name or "Unknown")
|
||||||
|
- Phone
|
||||||
|
- Duration (formatted: 5m 12s)
|
||||||
|
- Outcome (disposition badge: Appointment Booked, Follow-up Scheduled, Info Provided, Missed, No Answer)
|
||||||
|
- Agent
|
||||||
|
- Recording (Play button — uses `recordingUrl` from webhook data)
|
||||||
|
- Time (formatted datetime)
|
||||||
|
- Actions (View Details link)
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Search by patient name or phone
|
||||||
|
- Filter dropdown: All Calls / Inbound / Outbound / Missed
|
||||||
|
- Sort by time (default: newest first)
|
||||||
|
|
||||||
|
Data source: `useData().calls` from DataProvider
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Rewrite `call-history.tsx` with Table component
|
||||||
|
- [ ] **Step 2:** Add search + filter dropdown
|
||||||
|
- [ ] **Step 3:** Add recording playback (native `<audio>` element in a popover or inline)
|
||||||
|
- [ ] **Step 4:** Type check and verify
|
||||||
|
- [ ] **Step 5:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Patients Listing Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/patients.tsx`
|
||||||
|
- Modify: `src/lib/queries.ts` — add PATIENTS_QUERY to DataProvider if not fetched
|
||||||
|
- Modify: `src/providers/data-provider.tsx` — fetch patients
|
||||||
|
- Modify: `src/main.tsx` — add `/patients` route
|
||||||
|
|
||||||
|
Patient table. Columns:
|
||||||
|
- Patient (name + age/gender)
|
||||||
|
- Contact (phone + email)
|
||||||
|
- Location (from address field if available)
|
||||||
|
- Last Visit (from appointments — most recent completed)
|
||||||
|
- Next Appt (from appointments — next scheduled)
|
||||||
|
- Status (active/inactive badge)
|
||||||
|
- Actions (Call button + View Details link to `/patient/:id`)
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Search by name or phone
|
||||||
|
- Filter by status (active/inactive)
|
||||||
|
- "Add Patient" button (opens modal or navigates to form)
|
||||||
|
|
||||||
|
Data source: Need to add patients to DataProvider. Query: `{ patients(first: 50) { edges { node { id name fullName phones emails dateOfBirth gender patientType } } } }`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add patients to DataProvider + queries + transforms
|
||||||
|
- [ ] **Step 2:** Create `patients.tsx` page with Table
|
||||||
|
- [ ] **Step 3:** Add search + filter
|
||||||
|
- [ ] **Step 4:** Add route to main.tsx and sidebar nav
|
||||||
|
- [ ] **Step 5:** Type check and verify
|
||||||
|
- [ ] **Step 6:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Supervisor Dashboard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `src/pages/team-dashboard.tsx`
|
||||||
|
|
||||||
|
This is the admin/supervisor view. Sections:
|
||||||
|
|
||||||
|
**4a. KPI Cards (top row)**
|
||||||
|
- Total Calls (count from calls array)
|
||||||
|
- Inbound (filtered count)
|
||||||
|
- Outbound (filtered count)
|
||||||
|
- Missed (filtered count)
|
||||||
|
|
||||||
|
**4b. Performance Metrics (second row)**
|
||||||
|
- Avg Lead Response Time (computed from lead firstContacted - createdAt)
|
||||||
|
- Missed Callback Time (avg time since missed calls)
|
||||||
|
- Call → Appointment % (calls with disposition APPOINTMENT_BOOKED / total)
|
||||||
|
- Lead → Appointment % (leads with status APPOINTMENT_SET or CONVERTED / total)
|
||||||
|
|
||||||
|
**4c. Agent Performance Table**
|
||||||
|
Columns: Agent, Calls (In/Out/Missed), Avg Handle Time, Conversion %, Active/Idle
|
||||||
|
|
||||||
|
Data: Aggregate calls by agentName, compute per-agent metrics
|
||||||
|
|
||||||
|
**4d. Missed Call Queue**
|
||||||
|
List of recent missed calls with phone, age, priority
|
||||||
|
|
||||||
|
**4e. Supervisor AI Assistant**
|
||||||
|
Reuse existing AiChatPanel with supervisor-specific quick prompts:
|
||||||
|
- "Which agents have the highest conversions?"
|
||||||
|
- "How many leads are pending contact?"
|
||||||
|
- "Top missed callback risks?"
|
||||||
|
|
||||||
|
Data source: `useData().calls`, `useData().leads`, computed aggregations
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Build KPI card component (reusable)
|
||||||
|
- [ ] **Step 2:** Build agent performance table
|
||||||
|
- [ ] **Step 3:** Build missed call queue section
|
||||||
|
- [ ] **Step 4:** Add AI assistant section
|
||||||
|
- [ ] **Step 5:** Wire Day/Week/Month filter (client-side date filtering)
|
||||||
|
- [ ] **Step 6:** Type check and verify
|
||||||
|
- [ ] **Step 7:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Reports Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/pages/reports.tsx`
|
||||||
|
- Modify: `src/main.tsx` — add `/reports` route (or reuse `/analytics`)
|
||||||
|
|
||||||
|
Charts page using Apache ECharts.
|
||||||
|
|
||||||
|
**5a. KPI Cards (top row)**
|
||||||
|
- Total Calls (with +X% trend vs previous period)
|
||||||
|
- Inbound
|
||||||
|
- Outbound
|
||||||
|
- Conversion %
|
||||||
|
|
||||||
|
**5b. Call Volume Trend (bar chart)**
|
||||||
|
- X axis: days of week (Mon-Sun) or last 7/30 days
|
||||||
|
- Y axis: call count
|
||||||
|
- Two series: Inbound (gray) + Outbound (blue)
|
||||||
|
- Use `echarts-for-react` with bar chart type
|
||||||
|
|
||||||
|
**5c. Call Outcomes (donut chart)**
|
||||||
|
- Segments: Booked, Follow-up, Info Only, Missed
|
||||||
|
- Computed from call dispositions
|
||||||
|
- Center label: total count
|
||||||
|
|
||||||
|
Data source: `useData().calls` aggregated by date and disposition
|
||||||
|
|
||||||
|
ECharts pattern:
|
||||||
|
```tsx
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
xAxis: { type: 'category', data: ['Mon', 'Tue', ...] },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [
|
||||||
|
{ name: 'Inbound', type: 'bar', data: [...] },
|
||||||
|
{ name: 'Outbound', type: 'bar', data: [...] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
<ReactECharts option={option} style={{ height: 400 }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Build KPI cards with trend indicators
|
||||||
|
- [ ] **Step 2:** Build Call Volume Trend bar chart
|
||||||
|
- [ ] **Step 3:** Build Call Outcomes donut chart
|
||||||
|
- [ ] **Step 4:** Wire date range filter
|
||||||
|
- [ ] **Step 5:** Add route to main.tsx and sidebar nav
|
||||||
|
- [ ] **Step 6:** Type check and verify
|
||||||
|
- [ ] **Step 7:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Live Call Monitoring (Supervisor)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/dashboard/live-calls-panel.tsx`
|
||||||
|
- Modify: `src/pages/team-dashboard.tsx` — add live calls section
|
||||||
|
|
||||||
|
This requires real-time data from Ozonetel. Two approaches:
|
||||||
|
|
||||||
|
**Option A: Polling** — hit Ozonetel's agent status API every 10 seconds
|
||||||
|
**Option B: WebSocket** — if Ozonetel exposes real-time WebSocket feeds
|
||||||
|
|
||||||
|
For MVP, use polling via the sidecar:
|
||||||
|
- New sidecar endpoint: `GET /api/agents/live` — queries Ozonetel for current agent states
|
||||||
|
- Returns: `[{ agentId, name, status, currentCall: { phone, duration, direction } }]`
|
||||||
|
|
||||||
|
The live calls panel shows:
|
||||||
|
- Agent name + phone number + Inbound/Outbound badge
|
||||||
|
- Call duration (live timer)
|
||||||
|
- Whisper button (joins call silently — needs Ozonetel conference API)
|
||||||
|
- Barge-in button (joins call audibly — needs Ozonetel conference API)
|
||||||
|
|
||||||
|
**NOTE:** Whisper/Barge-in require Ozonetel's conference/monitor APIs which may have the same auth issue as the dial API. Research needed.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Research Ozonetel live agent status API
|
||||||
|
- [ ] **Step 2:** Create sidecar endpoint `GET /api/agents/live`
|
||||||
|
- [ ] **Step 3:** Build `live-calls-panel.tsx` with polling
|
||||||
|
- [ ] **Step 4:** Add to supervisor dashboard
|
||||||
|
- [ ] **Step 5:** Research whisper/barge-in APIs
|
||||||
|
- [ ] **Step 6:** Implement whisper/barge-in buttons (if API available)
|
||||||
|
- [ ] **Step 7:** Commit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Deploy and Verify
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Build frontend with production env vars
|
||||||
|
- [ ] **Step 2:** Upload to VPS
|
||||||
|
- [ ] **Step 3:** Rebuild sidecar if changed, push to VPS
|
||||||
|
- [ ] **Step 4:** Test all pages as each role (admin, cc-agent, executive)
|
||||||
|
- [ ] **Step 5:** Commit all changes
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
import type { FC, HTMLAttributes } from 'react';
|
import type { FC, HTMLAttributes } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPhoneXmark, faBell, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
import {
|
||||||
|
faPhoneArrowDown,
|
||||||
|
faPhoneArrowUp,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { SearchLg } from '@untitledui/icons';
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
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 { ClickToCallButton } from './click-to-call-button';
|
import { ClickToCallButton } from './click-to-call-button';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
@@ -20,6 +28,7 @@ type WorklistLead = {
|
|||||||
|
|
||||||
type WorklistFollowUp = {
|
type WorklistFollowUp = {
|
||||||
id: string;
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
followUpType: string | null;
|
followUpType: string | null;
|
||||||
followUpStatus: string | null;
|
followUpStatus: string | null;
|
||||||
scheduledAt: string | null;
|
scheduledAt: string | null;
|
||||||
@@ -29,6 +38,7 @@ type WorklistFollowUp = {
|
|||||||
type MissedCall = {
|
type MissedCall = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
callDirection: string | null;
|
||||||
callerNumber: { number: string; callingCode: string }[] | null;
|
callerNumber: { number: string; callingCode: string }[] | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
@@ -43,54 +53,184 @@ interface WorklistPanelProps {
|
|||||||
selectedLeadId: string | null;
|
selectedLeadId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconMissed: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
|
||||||
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
|
|
||||||
);
|
|
||||||
const IconFollowUp: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faBell} className={className} />
|
|
||||||
);
|
|
||||||
const IconLeads: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faUsers} className={className} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatAge = (dateStr: string): string => {
|
// Unified row type for the table
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
type WorklistRow = {
|
||||||
if (minutes < 1) return 'Just now';
|
id: string;
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||||
const hours = Math.floor(minutes / 60);
|
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
|
||||||
if (hours < 24) return `${hours}h ago`;
|
name: string;
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
phone: string;
|
||||||
|
phoneRaw: string;
|
||||||
|
direction: 'inbound' | 'outbound' | null;
|
||||||
|
typeLabel: string;
|
||||||
|
reason: string;
|
||||||
|
createdAt: string;
|
||||||
|
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||||
|
leadId: string | null;
|
||||||
|
originalLead: WorklistLead | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||||
|
URGENT: { color: 'error', label: 'Urgent', sort: 0 },
|
||||||
|
HIGH: { color: 'warning', label: 'High', sort: 1 },
|
||||||
|
NORMAL: { color: 'brand', label: 'Normal', sort: 2 },
|
||||||
|
LOW: { color: 'gray', label: 'Low', sort: 3 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const followUpLabel: Record<string, string> = {
|
const followUpLabel: Record<string, string> = {
|
||||||
CALLBACK: 'Callback',
|
CALLBACK: 'Callback',
|
||||||
APPOINTMENT_REMINDER: 'Appointment Reminder',
|
APPOINTMENT_REMINDER: 'Appt Reminder',
|
||||||
POST_VISIT: 'Post-visit Follow-up',
|
POST_VISIT: 'Post-visit',
|
||||||
MARKETING: 'Marketing',
|
MARKETING: 'Marketing',
|
||||||
REVIEW_REQUEST: 'Review Request',
|
REVIEW_REQUEST: 'Review',
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string }> = {
|
// Compute SLA: minutes since created, color-coded
|
||||||
URGENT: { color: 'error', label: 'Urgent' },
|
const computeSla = (createdAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
HIGH: { color: 'warning', label: 'High' },
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(createdAt).getTime()) / 60000));
|
||||||
NORMAL: { color: 'brand', label: 'Normal' },
|
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||||
LOW: { color: 'gray', label: 'Low' },
|
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||||
|
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
|
||||||
|
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
|
||||||
|
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const SectionHeader = ({ icon: Icon, title, count, color }: {
|
const IconInbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
icon: FC<HTMLAttributes<HTMLOrSVGElement>>;
|
<FontAwesomeIcon icon={faPhoneArrowDown} className={className} />
|
||||||
title: string;
|
);
|
||||||
count: number;
|
const IconOutbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
color: 'error' | 'blue' | 'brand';
|
<FontAwesomeIcon icon={faPhoneArrowUp} className={className} />
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
|
||||||
<Icon className="size-4 text-fg-quaternary" />
|
|
||||||
<span className="text-xs font-bold text-tertiary uppercase tracking-wider">{title}</span>
|
|
||||||
{count > 0 && <Badge size="sm" color={color} type="pill-color">{count}</Badge>}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], leads: WorklistLead[]): WorklistRow[] => {
|
||||||
|
const rows: WorklistRow[] = [];
|
||||||
|
|
||||||
|
for (const call of missedCalls) {
|
||||||
|
const phone = call.callerNumber?.[0];
|
||||||
|
rows.push({
|
||||||
|
id: `mc-${call.id}`,
|
||||||
|
type: 'missed',
|
||||||
|
priority: 'HIGH',
|
||||||
|
name: phone ? formatPhone(phone) : 'Unknown',
|
||||||
|
phone: phone ? formatPhone(phone) : '',
|
||||||
|
phoneRaw: phone?.number ?? '',
|
||||||
|
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||||
|
typeLabel: 'Missed Call',
|
||||||
|
reason: call.startedAt
|
||||||
|
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}`
|
||||||
|
: 'Missed call',
|
||||||
|
createdAt: call.createdAt,
|
||||||
|
taskState: 'PENDING',
|
||||||
|
leadId: call.leadId,
|
||||||
|
originalLead: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fu of followUps) {
|
||||||
|
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||||
|
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||||
|
rows.push({
|
||||||
|
id: `fu-${fu.id}`,
|
||||||
|
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||||
|
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||||
|
name: label,
|
||||||
|
phone: '',
|
||||||
|
phoneRaw: '',
|
||||||
|
direction: null,
|
||||||
|
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||||
|
reason: fu.scheduledAt
|
||||||
|
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
|
||||||
|
: '',
|
||||||
|
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||||
|
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||||
|
leadId: null,
|
||||||
|
originalLead: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lead of leads) {
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
|
const phone = lead.contactPhone?.[0];
|
||||||
|
rows.push({
|
||||||
|
id: `lead-${lead.id}`,
|
||||||
|
type: 'lead',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
name: fullName,
|
||||||
|
phone: phone ? formatPhone(phone) : '',
|
||||||
|
phoneRaw: phone?.number ?? '',
|
||||||
|
direction: null,
|
||||||
|
typeLabel: 'Lead',
|
||||||
|
reason: lead.interestedService ?? lead.aiSuggestedAction ?? '',
|
||||||
|
createdAt: lead.createdAt,
|
||||||
|
taskState: 'PENDING',
|
||||||
|
leadId: lead.id,
|
||||||
|
originalLead: lead,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority (urgent first), then by creation time (oldest first)
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeConfig: Record<WorklistRow['type'], { color: 'error' | 'brand' | 'blue-light' | 'gray' }> = {
|
||||||
|
missed: { color: 'error' },
|
||||||
|
callback: { color: 'brand' },
|
||||||
|
'follow-up': { color: 'blue-light' },
|
||||||
|
lead: { color: 'gray' },
|
||||||
|
};
|
||||||
|
|
||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
||||||
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const allRows = useMemo(
|
||||||
|
() => buildRows(missedCalls, followUps, leads),
|
||||||
|
[missedCalls, followUps, leads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
let rows = allRows;
|
||||||
|
|
||||||
|
// Tab filter
|
||||||
|
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed');
|
||||||
|
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback');
|
||||||
|
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
rows = rows.filter(
|
||||||
|
(r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}, [allRows, tab, search]);
|
||||||
|
|
||||||
|
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||||
|
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
|
||||||
|
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
||||||
|
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
|
||||||
|
{ id: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : undefined },
|
||||||
|
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -111,112 +251,124 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-secondary">
|
<TableCard.Root size="sm">
|
||||||
{/* Missed calls */}
|
<TableCard.Header
|
||||||
{missedCalls.length > 0 && (
|
title="Worklist"
|
||||||
<div>
|
badge={String(allRows.length)}
|
||||||
<SectionHeader icon={IconMissed} title="Missed Calls" count={missedCalls.length} color="error" />
|
contentTrailing={
|
||||||
<div className="px-3 pb-3">
|
<div className="flex items-center gap-2">
|
||||||
{missedCalls.map((call) => {
|
<div className="w-48">
|
||||||
const phone = call.callerNumber?.[0];
|
<Input
|
||||||
const phoneDisplay = phone ? formatPhone(phone) : 'Unknown number';
|
placeholder="Search..."
|
||||||
const phoneNumber = phone?.number ?? '';
|
icon={SearchLg}
|
||||||
return (
|
size="sm"
|
||||||
<div key={call.id} className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
|
value={search}
|
||||||
<div className="min-w-0 flex-1">
|
onChange={(value) => setSearch(value)}
|
||||||
<div className="flex items-center gap-2">
|
aria-label="Search worklist"
|
||||||
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
|
/>
|
||||||
<Badge size="sm" color="error" type="pill-color">
|
</div>
|
||||||
{call.createdAt ? formatAge(call.createdAt) : 'Unknown'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{call.startedAt && (
|
|
||||||
<p className="text-xs text-tertiary mt-0.5">
|
|
||||||
{new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} leadId={call.leadId ?? undefined} label="Call Back" size="sm" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* Follow-ups */}
|
{/* Filter tabs */}
|
||||||
{followUps.length > 0 && (
|
<div className="border-b border-secondary px-4">
|
||||||
<div>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as TabKey)}>
|
||||||
<SectionHeader icon={IconFollowUp} title="Follow-ups" count={followUps.length} color="blue" />
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
<div className="px-3 pb-3 space-y-1">
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
{followUps.map((fu) => {
|
</TabList>
|
||||||
const isOverdue = fu.followUpStatus === 'OVERDUE' ||
|
</Tabs>
|
||||||
(fu.scheduledAt && new Date(fu.scheduledAt) < new Date());
|
</div>
|
||||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
|
||||||
const priority = priorityConfig[fu.priority ?? 'NORMAL'] ?? priorityConfig.NORMAL;
|
{filteredRows.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-sm text-quaternary">
|
||||||
|
{search ? 'No matching items' : 'No items in this category'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="PRIORITY" className="w-20" />
|
||||||
|
<Table.Head label="PATIENT" />
|
||||||
|
<Table.Head label="PHONE" />
|
||||||
|
<Table.Head label="TYPE" />
|
||||||
|
<Table.Head label="SLA" className="w-20" />
|
||||||
|
<Table.Head label="ACTIONS" className="w-24" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={filteredRows}>
|
||||||
|
{(row) => {
|
||||||
|
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||||
|
const sla = computeSla(row.createdAt);
|
||||||
|
const typeCfg = typeConfig[row.type];
|
||||||
|
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={fu.id} className={cx(
|
<Table.Row
|
||||||
"rounded-lg px-3 py-2.5 transition duration-100 ease-linear",
|
id={row.id}
|
||||||
isOverdue ? "bg-error-primary" : "hover:bg-primary_hover",
|
|
||||||
)}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-primary">{label}</span>
|
|
||||||
{isOverdue && <Badge size="sm" color="error" type="pill-color">Overdue</Badge>}
|
|
||||||
<Badge size="sm" color={priority.color} type="pill-color">{priority.label}</Badge>
|
|
||||||
</div>
|
|
||||||
{fu.scheduledAt && (
|
|
||||||
<p className="text-xs text-tertiary mt-0.5">
|
|
||||||
{new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assigned leads */}
|
|
||||||
{leads.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<SectionHeader icon={IconLeads} title="Assigned Leads" count={leads.length} color="brand" />
|
|
||||||
<div className="px-3 pb-3 space-y-1">
|
|
||||||
{leads.map((lead) => {
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
|
||||||
const phone = lead.contactPhone?.[0];
|
|
||||||
const phoneDisplay = phone ? formatPhone(phone) : '';
|
|
||||||
const phoneNumber = phone?.number ?? '';
|
|
||||||
const isSelected = lead.id === selectedLeadId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={lead.id}
|
|
||||||
onClick={() => onSelectLead(lead)}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition duration-100 ease-linear",
|
'cursor-pointer',
|
||||||
isSelected ? "bg-brand-primary ring-1 ring-brand" : "hover:bg-primary_hover",
|
isSelected && 'bg-brand-primary',
|
||||||
)}
|
)}
|
||||||
|
onAction={() => {
|
||||||
|
if (row.originalLead) {
|
||||||
|
onSelectLead(row.originalLead);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<Table.Cell>
|
||||||
<div className="flex items-baseline gap-2">
|
<Badge size="sm" color={priority.color} type="pill-color">
|
||||||
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
{priority.label}
|
||||||
{phoneDisplay && <span className="text-xs text-tertiary">{phoneDisplay}</span>}
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{row.direction === 'inbound' && (
|
||||||
|
<IconInbound className="size-3.5 text-fg-success-secondary" />
|
||||||
|
)}
|
||||||
|
{row.direction === 'outbound' && (
|
||||||
|
<IconOutbound className="size-3.5 text-fg-brand-secondary" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-primary truncate max-w-[140px]">
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{lead.interestedService && (
|
</Table.Cell>
|
||||||
<p className="text-xs text-quaternary mt-0.5">{lead.interestedService}</p>
|
<Table.Cell>
|
||||||
)}
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||||
</div>
|
{row.phone || '\u2014'}
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} leadId={lead.id} size="sm" />
|
</span>
|
||||||
</div>
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={typeCfg.color} type="pill-color">
|
||||||
|
{row.typeLabel}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={sla.color} type="pill-color">
|
||||||
|
{sla.label}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{row.phoneRaw ? (
|
||||||
|
<ClickToCallButton
|
||||||
|
phoneNumber={row.phoneRaw}
|
||||||
|
leadId={row.leadId ?? undefined}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-quaternary">No phone</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</div>
|
</Table.Body>
|
||||||
</div>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableCard.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
faCommentDots,
|
faCommentDots,
|
||||||
faGear,
|
faGear,
|
||||||
faGrid2,
|
faGrid2,
|
||||||
|
faHospitalUser,
|
||||||
faPhone,
|
faPhone,
|
||||||
faPlug,
|
faPlug,
|
||||||
faUsers,
|
faUsers,
|
||||||
@@ -58,6 +59,9 @@ const IconClockRewind: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) =>
|
|||||||
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
<FontAwesomeIcon icon={faUsers} className={className} />
|
<FontAwesomeIcon icon={faUsers} className={className} />
|
||||||
);
|
);
|
||||||
|
const IconHospitalUser: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faHospitalUser} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
type NavSection = {
|
type NavSection = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -70,7 +74,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
|
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
|
||||||
{ label: 'Management', items: [
|
{ label: 'Management', items: [
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
|
||||||
]},
|
]},
|
||||||
{ label: 'Admin', items: [
|
{ label: 'Admin', items: [
|
||||||
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
|
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
|
||||||
@@ -83,6 +87,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
return [
|
return [
|
||||||
{ label: 'Call Center', items: [
|
{ label: 'Call Center', items: [
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
]},
|
]},
|
||||||
@@ -93,11 +98,12 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Main', items: [
|
{ label: 'Main', items: [
|
||||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||||
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
||||||
]},
|
]},
|
||||||
{ label: 'Insights', items: [
|
{ label: 'Insights', items: [
|
||||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Transform platform GraphQL responses → frontend entity types
|
// Transform platform GraphQL responses → frontend entity types
|
||||||
// Platform remaps some field names during sync
|
// Platform remaps some field names during sync
|
||||||
|
|
||||||
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call } from '@/types/entities';
|
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient } from '@/types/entities';
|
||||||
|
|
||||||
type PlatformNode = Record<string, any>;
|
type PlatformNode = Record<string, any>;
|
||||||
|
|
||||||
@@ -150,3 +150,16 @@ export function transformCalls(data: any): Call[] {
|
|||||||
leadId: n.leadId,
|
leadId: n.leadId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformPatients(data: any): Patient[] {
|
||||||
|
return extractEdges(data, 'patients').map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
fullName: n.fullName ?? null,
|
||||||
|
phones: n.phones ?? null,
|
||||||
|
emails: n.emails ?? null,
|
||||||
|
dateOfBirth: n.dateOfBirth ?? null,
|
||||||
|
gender: n.gender ?? null,
|
||||||
|
patientType: n.patientType ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { FollowUpsPage } from "@/pages/follow-ups-page";
|
|||||||
import { LoginPage } from "@/pages/login";
|
import { LoginPage } from "@/pages/login";
|
||||||
import { OutreachPage } from "@/pages/outreach";
|
import { OutreachPage } from "@/pages/outreach";
|
||||||
import { Patient360Page } from "@/pages/patient-360";
|
import { Patient360Page } from "@/pages/patient-360";
|
||||||
|
import { ReportsPage } from "@/pages/reports";
|
||||||
|
import { PatientsPage } from "@/pages/patients";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
@@ -47,7 +49,9 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
|
<Route path="/patients" element={<PatientsPage />} />
|
||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -75,16 +75,14 @@ export const CallDeskPage = () => {
|
|||||||
|
|
||||||
{/* Worklist (visible when idle) */}
|
{/* Worklist (visible when idle) */}
|
||||||
{!isInCall && (
|
{!isInCall && (
|
||||||
<div className="rounded-xl border border-secondary bg-primary">
|
<WorklistPanel
|
||||||
<WorklistPanel
|
missedCalls={missedCalls}
|
||||||
missedCalls={missedCalls}
|
followUps={followUps}
|
||||||
followUps={followUps}
|
leads={marketingLeads}
|
||||||
leads={marketingLeads}
|
loading={loading}
|
||||||
loading={loading}
|
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
selectedLeadId={selectedLead?.id ?? null}
|
||||||
selectedLeadId={selectedLead?.id ?? null}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Today's calls — always visible */}
|
{/* Today's calls — always visible */}
|
||||||
|
|||||||
@@ -1,124 +1,292 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faPhoneArrowDown,
|
||||||
|
faPhoneArrowUp,
|
||||||
|
faPhoneXmark,
|
||||||
|
faPlay,
|
||||||
|
faPause,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { SearchLg } from '@untitledui/icons';
|
||||||
|
import { Table, TableCard } 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 { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { formatShortDate } from '@/lib/format';
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
|
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||||
import type { CallDisposition } from '@/types/entities';
|
|
||||||
|
|
||||||
const dispositionColor = (disposition: CallDisposition | null): 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' => {
|
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||||
switch (disposition) {
|
|
||||||
case 'APPOINTMENT_BOOKED':
|
|
||||||
return 'success';
|
|
||||||
case 'FOLLOW_UP_SCHEDULED':
|
|
||||||
return 'brand';
|
|
||||||
case 'INFO_PROVIDED':
|
|
||||||
return 'blue-light';
|
|
||||||
case 'NO_ANSWER':
|
|
||||||
return 'warning';
|
|
||||||
case 'WRONG_NUMBER':
|
|
||||||
return 'gray';
|
|
||||||
case 'CALLBACK_REQUESTED':
|
|
||||||
return 'brand';
|
|
||||||
default:
|
|
||||||
return 'gray';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDispositionLabel = (disposition: CallDisposition | null): string => {
|
const filterItems = [
|
||||||
if (!disposition) return '—';
|
{ id: 'all' as const, label: 'All Calls' },
|
||||||
return disposition
|
{ id: 'inbound' as const, label: 'Inbound' },
|
||||||
.toLowerCase()
|
{ id: 'outbound' as const, label: 'Outbound' },
|
||||||
.replace(/_/g, ' ')
|
{ id: 'missed' as const, label: 'Missed' },
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
];
|
||||||
|
|
||||||
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
|
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||||
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||||
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
if (seconds === null) return '—';
|
if (seconds === null || seconds === 0) return '\u2014';
|
||||||
const mins = Math.round(seconds / 60);
|
if (seconds < 60) return `${seconds}s`;
|
||||||
return mins === 0 ? '<1 min' : `${mins} min`;
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCallerNumber = (callerNumber: { number: string; callingCode: string }[] | null): string => {
|
const formatPhoneDisplay = (call: Call): string => {
|
||||||
if (!callerNumber || callerNumber.length === 0) return '—';
|
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||||
const first = callerNumber[0];
|
return formatPhone(call.callerNumber[0]);
|
||||||
return `${first.callingCode} ${first.number}`;
|
}
|
||||||
|
return '\u2014';
|
||||||
|
};
|
||||||
|
|
||||||
|
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
||||||
|
if (status === 'MISSED') {
|
||||||
|
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
||||||
|
}
|
||||||
|
if (direction === 'OUTBOUND') {
|
||||||
|
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
||||||
|
}
|
||||||
|
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audio.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audio.play().catch(() => setIsPlaying(false));
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => setIsPlaying(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<audio ref={audioRef} src={url} preload="none" onEnded={handleEnded} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary"
|
||||||
|
iconLeading={
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isPlaying ? faPause : faPlay}
|
||||||
|
data-icon
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CallHistoryPage = () => {
|
export const CallHistoryPage = () => {
|
||||||
const { calls } = useData();
|
const { calls, leads } = useData();
|
||||||
const { user } = useAuth();
|
const [search, setSearch] = useState('');
|
||||||
|
const [filter, setFilter] = useState<FilterKey>('all');
|
||||||
|
|
||||||
const agentCalls = calls
|
// Build a map of lead names by ID for enrichment
|
||||||
.filter((call) => call.agentName === user.name)
|
const leadNameMap = useMemo(() => {
|
||||||
.sort((a, b) => {
|
const map = new Map<string, string>();
|
||||||
|
for (const lead of leads) {
|
||||||
|
if (lead.id && lead.contactName) {
|
||||||
|
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
|
||||||
|
if (name) map.set(lead.id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [leads]);
|
||||||
|
|
||||||
|
// Sort by time (newest first) and apply filters
|
||||||
|
const filteredCalls = useMemo(() => {
|
||||||
|
let result = [...calls].sort((a, b) => {
|
||||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
return dateB - dateA;
|
return dateB - dateA;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Direction / status filter
|
||||||
|
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
||||||
|
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||||
|
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
result = result.filter((c) => {
|
||||||
|
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
||||||
|
const phone = c.callerNumber?.[0]?.number ?? '';
|
||||||
|
const agent = c.agentName ?? '';
|
||||||
|
return (
|
||||||
|
name.toLowerCase().includes(q) ||
|
||||||
|
phone.includes(q) ||
|
||||||
|
agent.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [calls, filter, search, leadNameMap]);
|
||||||
|
|
||||||
|
const inboundCount = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
|
const outboundCount = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
|
const missedCount = calls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Call History" subtitle="All inbound calls" />
|
<TopBar title="Call History" subtitle={`${calls.length} total calls`} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-7">
|
<div className="flex-1 overflow-y-auto p-7">
|
||||||
{agentCalls.length === 0 ? (
|
<TableCard.Root size="md">
|
||||||
<div className="flex flex-1 items-center justify-center py-20">
|
<TableCard.Header
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
title="Call History"
|
||||||
|
badge={String(filteredCalls.length)}
|
||||||
|
description={`${inboundCount} inbound \u00B7 ${outboundCount} outbound \u00B7 ${missedCount} missed`}
|
||||||
|
contentTrailing={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-44">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="All Calls"
|
||||||
|
selectedKey={filter}
|
||||||
|
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||||
|
items={filterItems}
|
||||||
|
aria-label="Filter calls"
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item id={item.id} label={item.label}>
|
||||||
|
{item.label}
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filteredCalls.length === 0 ? (
|
||||||
|
<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>
|
||||||
<p className="text-sm text-tertiary">No call history available for your account yet.</p>
|
<p className="text-sm text-tertiary mt-1">
|
||||||
|
{search ? 'Try a different search term' : 'No call history available yet.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<Table>
|
||||||
<div className="rounded-2xl border border-secondary bg-primary overflow-hidden">
|
<Table.Header>
|
||||||
<table className="w-full">
|
<Table.Head label="TYPE" className="w-14" />
|
||||||
<thead>
|
<Table.Head label="PATIENT" />
|
||||||
<tr className="bg-secondary">
|
<Table.Head label="PHONE" />
|
||||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
<Table.Head label="DURATION" className="w-24" />
|
||||||
Date / Time
|
<Table.Head label="OUTCOME" />
|
||||||
</th>
|
<Table.Head label="AGENT" />
|
||||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
<Table.Head label="RECORDING" className="w-24" />
|
||||||
Caller
|
<Table.Head label="TIME" />
|
||||||
</th>
|
<Table.Head label="ACTIONS" className="w-24" />
|
||||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
</Table.Header>
|
||||||
Lead Name
|
<Table.Body items={filteredCalls}>
|
||||||
</th>
|
{(call) => {
|
||||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||||
Duration
|
const phoneDisplay = formatPhoneDisplay(call);
|
||||||
</th>
|
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||||
Disposition
|
|
||||||
</th>
|
return (
|
||||||
</tr>
|
<Table.Row id={call.id}>
|
||||||
</thead>
|
<Table.Cell>
|
||||||
<tbody>
|
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
||||||
{agentCalls.map((call) => (
|
</Table.Cell>
|
||||||
<tr key={call.id} className="border-b border-tertiary hover:bg-primary_hover transition duration-100 ease-linear">
|
<Table.Cell>
|
||||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
|
||||||
{call.startedAt ? formatShortDate(call.startedAt) : '—'}
|
{patientName}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
</Table.Cell>
|
||||||
{formatCallerNumber(call.callerNumber)}
|
<Table.Cell>
|
||||||
</td>
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||||
<td className="px-4 py-3 text-sm text-primary">
|
{phoneDisplay}
|
||||||
{call.leadName ?? '—'}
|
</span>
|
||||||
</td>
|
</Table.Cell>
|
||||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
<Table.Cell>
|
||||||
{formatDuration(call.durationSeconds)}
|
<span className="text-sm text-secondary whitespace-nowrap">
|
||||||
</td>
|
{formatDuration(call.durationSeconds)}
|
||||||
<td className="px-4 py-3">
|
</span>
|
||||||
{call.disposition ? (
|
</Table.Cell>
|
||||||
<Badge size="sm" color={dispositionColor(call.disposition)}>
|
<Table.Cell>
|
||||||
{formatDispositionLabel(call.disposition)}
|
{dispositionCfg ? (
|
||||||
</Badge>
|
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
|
||||||
) : (
|
{dispositionCfg.label}
|
||||||
<span className="text-sm text-tertiary">—</span>
|
</Badge>
|
||||||
)}
|
) : (
|
||||||
</td>
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||||
</tr>
|
)}
|
||||||
))}
|
</Table.Cell>
|
||||||
</tbody>
|
<Table.Cell>
|
||||||
</table>
|
<span className="text-sm text-secondary">
|
||||||
</div>
|
{call.agentName ?? '\u2014'}
|
||||||
)}
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{call.recordingUrl ? (
|
||||||
|
<RecordingPlayer url={call.recordingUrl} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||||
|
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{phoneRaw ? (
|
||||||
|
<ClickToCallButton
|
||||||
|
phoneNumber={phoneRaw}
|
||||||
|
leadId={call.leadId ?? undefined}
|
||||||
|
label="Call"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TableCard.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
253
src/pages/patients.tsx
Normal file
253
src/pages/patients.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { SearchLg } from '@untitledui/icons';
|
||||||
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { getInitials } from '@/lib/format';
|
||||||
|
import type { Patient } from '@/types/entities';
|
||||||
|
|
||||||
|
const computeAge = (dateOfBirth: string | null): number | null => {
|
||||||
|
if (!dateOfBirth) return null;
|
||||||
|
const dob = new Date(dateOfBirth);
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - dob.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - dob.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatGender = (gender: string | null): string => {
|
||||||
|
if (!gender) return '';
|
||||||
|
switch (gender) {
|
||||||
|
case 'MALE': return 'M';
|
||||||
|
case 'FEMALE': return 'F';
|
||||||
|
case 'OTHER': return 'O';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatientDisplayName = (patient: Patient): string => {
|
||||||
|
if (patient.fullName) {
|
||||||
|
return `${patient.fullName.firstName} ${patient.fullName.lastName}`.trim();
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatientPhone = (patient: Patient): string => {
|
||||||
|
return patient.phones?.primaryPhoneNumber ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPatientEmail = (patient: Patient): string => {
|
||||||
|
return patient.emails?.primaryEmail ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PatientsPage = () => {
|
||||||
|
const { patients, loading } = useData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
|
||||||
|
const filteredPatients = useMemo(() => {
|
||||||
|
return patients.filter((patient) => {
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
const name = getPatientDisplayName(patient).toLowerCase();
|
||||||
|
const phone = getPatientPhone(patient).toLowerCase();
|
||||||
|
const email = getPatientEmail(patient).toLowerCase();
|
||||||
|
if (!name.includes(query) && !phone.includes(query) && !email.includes(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter — treat all patients as active for now since we don't have a status field
|
||||||
|
if (statusFilter === 'inactive') return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [patients, searchQuery, statusFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||||
|
<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">
|
||||||
|
{/* Status filter buttons */}
|
||||||
|
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||||
|
{(['all', 'active', 'inactive'] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => setStatusFilter(status)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
||||||
|
statusFilter === status
|
||||||
|
? 'bg-active text-brand-secondary'
|
||||||
|
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or phone..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(value) => setSearchQuery(value)}
|
||||||
|
aria-label="Search patients"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredPatients.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-2">
|
||||||
|
<FontAwesomeIcon icon={faUser} className="size-8 text-fg-quaternary" />
|
||||||
|
<h3 className="text-sm font-semibold text-primary">No patients found</h3>
|
||||||
|
<p className="text-sm text-tertiary">
|
||||||
|
{searchQuery ? 'Try adjusting your search.' : 'No patient records available yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="PATIENT" />
|
||||||
|
<Table.Head label="CONTACT" />
|
||||||
|
<Table.Head label="TYPE" />
|
||||||
|
<Table.Head label="GENDER" />
|
||||||
|
<Table.Head label="AGE" />
|
||||||
|
<Table.Head label="STATUS" />
|
||||||
|
<Table.Head label="ACTIONS" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={filteredPatients}>
|
||||||
|
{(patient) => {
|
||||||
|
const displayName = getPatientDisplayName(patient);
|
||||||
|
const age = computeAge(patient.dateOfBirth);
|
||||||
|
const gender = formatGender(patient.gender);
|
||||||
|
const phone = getPatientPhone(patient);
|
||||||
|
const email = getPatientEmail(patient);
|
||||||
|
const initials = patient.fullName
|
||||||
|
? getInitials(patient.fullName.firstName, patient.fullName.lastName)
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row id={patient.id}>
|
||||||
|
{/* Patient name + avatar */}
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar size="sm" initials={initials} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{(age !== null || gender) && (
|
||||||
|
<span className="text-xs text-tertiary">
|
||||||
|
{[
|
||||||
|
age !== null ? `${age}y` : null,
|
||||||
|
gender || null,
|
||||||
|
].filter(Boolean).join(' / ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{phone ? (
|
||||||
|
<span className="text-sm text-secondary">{phone}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-placeholder">No phone</span>
|
||||||
|
)}
|
||||||
|
{email ? (
|
||||||
|
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<Table.Cell>
|
||||||
|
{patient.patientType ? (
|
||||||
|
<Badge size="sm" color="gray">
|
||||||
|
{patient.patientType}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-placeholder">—</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-secondary">
|
||||||
|
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Age */}
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-secondary">
|
||||||
|
{age !== null ? `${age} yrs` : '—'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color="success" type="pill-color">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{phone && (
|
||||||
|
<ClickToCallButton
|
||||||
|
phoneNumber={phone}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="link-color"
|
||||||
|
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TableCard.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
347
src/pages/reports.tsx
Normal file
347
src/pages/reports.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faArrowTrendUp,
|
||||||
|
faArrowTrendDown,
|
||||||
|
faPhoneVolume,
|
||||||
|
faPhoneArrowDownLeft,
|
||||||
|
faPhoneArrowUpRight,
|
||||||
|
faPercent,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { BadgeWithIcon } from '@/components/base/badges/badges';
|
||||||
|
import { ArrowUp, ArrowDown } from '@untitledui/icons';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import type { Call } from '@/types/entities';
|
||||||
|
|
||||||
|
// Chart color palette — hardcoded from CSS tokens so ECharts can use them
|
||||||
|
const COLORS = {
|
||||||
|
brand600: 'rgb(21, 112, 239)',
|
||||||
|
brand500: 'rgb(59, 130, 246)',
|
||||||
|
gray300: 'rgb(213, 215, 218)',
|
||||||
|
gray400: 'rgb(164, 167, 174)',
|
||||||
|
success500: 'rgb(23, 178, 106)',
|
||||||
|
warning500: 'rgb(247, 144, 9)',
|
||||||
|
error500: 'rgb(240, 68, 56)',
|
||||||
|
purple500: 'rgb(158, 119, 237)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
const getLast7Days = (): { label: string; dateKey: string }[] => {
|
||||||
|
const days: { label: string; dateKey: string }[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(now.getDate() - i);
|
||||||
|
const label = date.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric' });
|
||||||
|
const dateKey = date.toISOString().slice(0, 10);
|
||||||
|
days.push({ label, dateKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupCallsByDate = (calls: Call[]): Record<string, { inbound: number; outbound: number }> => {
|
||||||
|
const grouped: Record<string, { inbound: number; outbound: number }> = {};
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const dateStr = call.startedAt ?? call.createdAt;
|
||||||
|
if (!dateStr) continue;
|
||||||
|
const dateKey = new Date(dateStr).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (!grouped[dateKey]) {
|
||||||
|
grouped[dateKey] = { inbound: 0, outbound: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.callDirection === 'INBOUND') {
|
||||||
|
grouped[dateKey].inbound++;
|
||||||
|
} else if (call.callDirection === 'OUTBOUND') {
|
||||||
|
grouped[dateKey].outbound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeTrendPercent = (current: number, previous: number): number => {
|
||||||
|
if (previous === 0) return current > 0 ? 100 : 0;
|
||||||
|
return Math.round(((current - previous) / previous) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Components
|
||||||
|
|
||||||
|
type KpiCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
trend: number;
|
||||||
|
icon: typeof faPhoneVolume;
|
||||||
|
iconColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KpiCard = ({ label, value, trend, icon, iconColor }: KpiCardProps) => {
|
||||||
|
const isPositive = trend >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl border border-secondary bg-primary p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-tertiary">{label}</span>
|
||||||
|
<FontAwesomeIcon icon={icon} className="size-5" style={{ color: iconColor }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<span className="text-display-sm font-bold text-primary">{value}</span>
|
||||||
|
<BadgeWithIcon
|
||||||
|
size="sm"
|
||||||
|
color={isPositive ? 'success' : 'error'}
|
||||||
|
iconLeading={isPositive ? ArrowUp : ArrowDown}
|
||||||
|
>
|
||||||
|
{Math.abs(trend)}%
|
||||||
|
</BadgeWithIcon>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-quaternary">vs previous 7 days</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReportsPage = () => {
|
||||||
|
const { calls, loading } = useData();
|
||||||
|
|
||||||
|
// Split current 7 days vs previous 7 days
|
||||||
|
const { currentCalls, previousCalls } = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const sevenDaysAgo = new Date(now);
|
||||||
|
sevenDaysAgo.setDate(now.getDate() - 7);
|
||||||
|
const fourteenDaysAgo = new Date(now);
|
||||||
|
fourteenDaysAgo.setDate(now.getDate() - 14);
|
||||||
|
|
||||||
|
const current: Call[] = [];
|
||||||
|
const previous: Call[] = [];
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const dateStr = call.startedAt ?? call.createdAt;
|
||||||
|
if (!dateStr) continue;
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
|
if (date >= sevenDaysAgo) {
|
||||||
|
current.push(call);
|
||||||
|
} else if (date >= fourteenDaysAgo) {
|
||||||
|
previous.push(call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentCalls: current, previousCalls: previous };
|
||||||
|
}, [calls]);
|
||||||
|
|
||||||
|
// KPI values
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
const totalCurrent = currentCalls.length;
|
||||||
|
const totalPrevious = previousCalls.length;
|
||||||
|
|
||||||
|
const inboundCurrent = currentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
|
const inboundPrevious = previousCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
|
|
||||||
|
const outboundCurrent = currentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
|
const outboundPrevious = previousCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
|
|
||||||
|
const bookedCurrent = currentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const bookedPrevious = previousCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const conversionCurrent = totalCurrent > 0 ? Math.round((bookedCurrent / totalCurrent) * 100) : 0;
|
||||||
|
const conversionPrevious = totalPrevious > 0 ? Math.round((bookedPrevious / totalPrevious) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: { value: totalCurrent, trend: computeTrendPercent(totalCurrent, totalPrevious) },
|
||||||
|
inbound: { value: inboundCurrent, trend: computeTrendPercent(inboundCurrent, inboundPrevious) },
|
||||||
|
outbound: { value: outboundCurrent, trend: computeTrendPercent(outboundCurrent, outboundPrevious) },
|
||||||
|
conversion: { value: conversionCurrent, trend: computeTrendPercent(conversionCurrent, conversionPrevious) },
|
||||||
|
};
|
||||||
|
}, [currentCalls, previousCalls]);
|
||||||
|
|
||||||
|
// Bar chart data — last 7 days
|
||||||
|
const barChartOption = useMemo(() => {
|
||||||
|
const days = getLast7Days();
|
||||||
|
const grouped = groupCallsByDate(calls);
|
||||||
|
|
||||||
|
const inboundData = days.map((d) => grouped[d.dateKey]?.inbound ?? 0);
|
||||||
|
const outboundData = days.map((d) => grouped[d.dateKey]?.outbound ?? 0);
|
||||||
|
const labels = days.map((d) => d.label);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis' as const,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: '#344054', fontSize: 12 },
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
itemWidth: 12,
|
||||||
|
itemHeight: 12,
|
||||||
|
textStyle: { color: '#667085', fontSize: 12 },
|
||||||
|
data: ['Inbound', 'Outbound'],
|
||||||
|
},
|
||||||
|
grid: { left: 40, right: 16, top: 16, bottom: 40 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category' as const,
|
||||||
|
data: labels,
|
||||||
|
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||||
|
axisTick: { show: false },
|
||||||
|
axisLabel: { color: '#667085', fontSize: 12 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value' as const,
|
||||||
|
splitLine: { lineStyle: { color: '#f2f4f7' } },
|
||||||
|
axisLabel: { color: '#667085', fontSize: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Inbound',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: inboundData,
|
||||||
|
barGap: '10%',
|
||||||
|
itemStyle: { color: COLORS.gray400, borderRadius: [4, 4, 0, 0] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Outbound',
|
||||||
|
type: 'bar' as const,
|
||||||
|
data: outboundData,
|
||||||
|
itemStyle: { color: COLORS.brand600, borderRadius: [4, 4, 0, 0] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [calls]);
|
||||||
|
|
||||||
|
// Donut chart data — call outcomes
|
||||||
|
const { donutOption, donutTotal } = useMemo(() => {
|
||||||
|
const booked = currentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const followUp = currentCalls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
||||||
|
const infoOnly = currentCalls.filter((c) => c.disposition === 'INFO_PROVIDED').length;
|
||||||
|
const missed = currentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
const other = currentCalls.length - booked - followUp - infoOnly - missed;
|
||||||
|
const total = currentCalls.length;
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: booked, name: 'Booked', itemStyle: { color: COLORS.success500 } },
|
||||||
|
{ value: followUp, name: 'Follow-up', itemStyle: { color: COLORS.brand600 } },
|
||||||
|
{ value: infoOnly, name: 'Info Only', itemStyle: { color: COLORS.purple500 } },
|
||||||
|
{ value: missed, name: 'Missed', itemStyle: { color: COLORS.error500 } },
|
||||||
|
...(other > 0 ? [{ value: other, name: 'Other', itemStyle: { color: COLORS.gray300 } }] : []),
|
||||||
|
].filter((d) => d.value > 0);
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item' as const,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderWidth: 1,
|
||||||
|
textStyle: { color: '#344054', fontSize: 12 },
|
||||||
|
formatter: (params: { name: string; value: number; percent: number }) =>
|
||||||
|
`${params.name}: ${params.value} (${params.percent}%)`,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
itemWidth: 12,
|
||||||
|
itemHeight: 12,
|
||||||
|
textStyle: { color: '#667085', fontSize: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'pie' as const,
|
||||||
|
radius: ['55%', '80%'],
|
||||||
|
center: ['50%', '45%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'center' as const,
|
||||||
|
formatter: () => `${total}`,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#101828',
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: { show: true, fontSize: 28, fontWeight: 700 },
|
||||||
|
},
|
||||||
|
labelLine: { show: false },
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return { donutOption: option, donutTotal: total };
|
||||||
|
}, [currentCalls]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<TopBar title="Reports" subtitle="Call analytics and insights" />
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<p className="text-sm text-tertiary">Loading data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Reports" subtitle="Call analytics and insights" />
|
||||||
|
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="Total Calls"
|
||||||
|
value={kpis.total.value}
|
||||||
|
trend={kpis.total.trend}
|
||||||
|
icon={faPhoneVolume}
|
||||||
|
iconColor={COLORS.brand600}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Inbound"
|
||||||
|
value={kpis.inbound.value}
|
||||||
|
trend={kpis.inbound.trend}
|
||||||
|
icon={faPhoneArrowDownLeft}
|
||||||
|
iconColor={COLORS.gray400}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Outbound"
|
||||||
|
value={kpis.outbound.value}
|
||||||
|
trend={kpis.outbound.trend}
|
||||||
|
icon={faPhoneArrowUpRight}
|
||||||
|
iconColor={COLORS.success500}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Conversion %"
|
||||||
|
value={`${kpis.conversion.value}%`}
|
||||||
|
trend={kpis.conversion.trend}
|
||||||
|
icon={faPercent}
|
||||||
|
iconColor={COLORS.warning500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts row */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
|
{/* Call Volume Trend — 2/3 width */}
|
||||||
|
<div className="col-span-1 xl:col-span-2 rounded-xl border border-secondary bg-primary p-5">
|
||||||
|
<h2 className="text-md font-semibold text-primary mb-1">Call Volume Trend</h2>
|
||||||
|
<p className="text-sm text-tertiary mb-4">Inbound vs outbound calls — last 7 days</p>
|
||||||
|
<ReactECharts option={barChartOption} style={{ height: 340 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Outcomes Donut — 1/3 width */}
|
||||||
|
<div className="col-span-1 rounded-xl border border-secondary bg-primary p-5">
|
||||||
|
<h2 className="text-md font-semibold text-primary mb-1">Call Outcomes</h2>
|
||||||
|
<p className="text-sm text-tertiary mb-4">Disposition breakdown — last 7 days</p>
|
||||||
|
{donutTotal === 0 ? (
|
||||||
|
<div className="flex h-[340px] items-center justify-center">
|
||||||
|
<p className="text-sm text-tertiary">No call data in this period</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReactECharts option={donutOption} style={{ height: 340 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,29 +1,456 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faPhone,
|
||||||
|
faPhoneArrowDownLeft,
|
||||||
|
faPhoneArrowUpRight,
|
||||||
|
faPhoneMissed,
|
||||||
|
faClock,
|
||||||
|
faCalendarCheck,
|
||||||
|
faUserHeadset,
|
||||||
|
faChartMixed,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { TeamScoreboard } from '@/components/admin/team-scoreboard';
|
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||||
import { CampaignRoiCards } from '@/components/admin/campaign-roi-cards';
|
|
||||||
import { LeadFunnel } from '@/components/admin/lead-funnel';
|
|
||||||
import { SlaMetrics } from '@/components/admin/sla-metrics';
|
|
||||||
import { IntegrationHealth } from '@/components/admin/integration-health';
|
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
|
||||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { getInitials, formatShortDate } from '@/lib/format';
|
||||||
|
import type { Call } from '@/types/entities';
|
||||||
|
|
||||||
|
// KPI Card component
|
||||||
|
type KpiCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: IconDefinition;
|
||||||
|
iconColor: string;
|
||||||
|
iconBg: string;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle }: KpiCardProps) => (
|
||||||
|
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||||
|
<div className={`flex size-12 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||||
|
<FontAwesomeIcon icon={icon} className={`size-5 ${iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||||
|
<span className="text-display-xs font-bold text-primary">{value}</span>
|
||||||
|
{subtitle && <span className="text-xs text-tertiary">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Metric card for performance row
|
||||||
|
type MetricCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricCard = ({ label, value, description }: MetricCardProps) => (
|
||||||
|
<div className="flex flex-1 flex-col gap-1 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||||
|
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||||
|
<span className="text-lg font-bold text-primary">{value}</span>
|
||||||
|
<span className="text-xs text-tertiary">{description}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type DateRange = 'today' | 'week' | 'month';
|
||||||
|
|
||||||
|
const getDateRangeStart = (range: DateRange): Date => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (range) {
|
||||||
|
case 'today': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
case 'week': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 7);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 30);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number): string => {
|
||||||
|
if (isNaN(value) || !isFinite(value)) return '0%';
|
||||||
|
return `${Math.round(value)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentPerformance = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
initials: string;
|
||||||
|
inboundCalls: number;
|
||||||
|
outboundCalls: number;
|
||||||
|
missedCalls: number;
|
||||||
|
totalCalls: number;
|
||||||
|
avgHandleTime: number;
|
||||||
|
appointmentsBooked: number;
|
||||||
|
conversionRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const TeamDashboardPage = () => {
|
export const TeamDashboardPage = () => {
|
||||||
const { leads } = useLeads();
|
const { calls, leads, followUps, loading } = useData();
|
||||||
const { campaigns } = useCampaigns();
|
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||||
const { calls, agents, ingestionSources } = useData();
|
|
||||||
|
// Filter calls by date range
|
||||||
|
const filteredCalls = useMemo(() => {
|
||||||
|
const rangeStart = getDateRangeStart(dateRange);
|
||||||
|
return calls.filter((call) => {
|
||||||
|
if (!call.startedAt) return false;
|
||||||
|
return new Date(call.startedAt) >= rangeStart;
|
||||||
|
});
|
||||||
|
}, [calls, dateRange]);
|
||||||
|
|
||||||
|
// KPI computations
|
||||||
|
const totalCalls = filteredCalls.length;
|
||||||
|
const inboundCalls = filteredCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
|
const outboundCalls = filteredCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
|
const missedCalls = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
const avgResponseTime = useMemo(() => {
|
||||||
|
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||||
|
if (leadsWithResponse.length === 0) return null;
|
||||||
|
const totalMinutes = leadsWithResponse.reduce((sum, l) => {
|
||||||
|
const created = new Date(l.createdAt!).getTime();
|
||||||
|
const contacted = new Date(l.firstContactedAt!).getTime();
|
||||||
|
return sum + (contacted - created) / 60000;
|
||||||
|
}, 0);
|
||||||
|
return Math.round(totalMinutes / leadsWithResponse.length);
|
||||||
|
}, [leads]);
|
||||||
|
|
||||||
|
const missedCallbackTime = useMemo(() => {
|
||||||
|
const missedCallsList = filteredCalls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
|
||||||
|
if (missedCallsList.length === 0) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const totalMinutes = missedCallsList.reduce((sum, c) => {
|
||||||
|
return sum + (now - new Date(c.startedAt!).getTime()) / 60000;
|
||||||
|
}, 0);
|
||||||
|
return Math.round(totalMinutes / missedCallsList.length);
|
||||||
|
}, [filteredCalls]);
|
||||||
|
|
||||||
|
const callToAppointmentRate = useMemo(() => {
|
||||||
|
if (totalCalls === 0) return 0;
|
||||||
|
const booked = filteredCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
return (booked / totalCalls) * 100;
|
||||||
|
}, [filteredCalls, totalCalls]);
|
||||||
|
|
||||||
|
const leadToAppointmentRate = useMemo(() => {
|
||||||
|
if (leads.length === 0) return 0;
|
||||||
|
const converted = leads.filter(
|
||||||
|
(l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED',
|
||||||
|
).length;
|
||||||
|
return (converted / leads.length) * 100;
|
||||||
|
}, [leads]);
|
||||||
|
|
||||||
|
// Agent performance table data
|
||||||
|
const agentPerformance = useMemo((): AgentPerformance[] => {
|
||||||
|
const agentMap = new Map<string, Call[]>();
|
||||||
|
for (const call of filteredCalls) {
|
||||||
|
const agent = call.agentName ?? 'Unknown';
|
||||||
|
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||||
|
agentMap.get(agent)!.push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
||||||
|
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
|
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
|
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
const total = agentCalls.length;
|
||||||
|
const totalDuration = agentCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||||
|
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||||
|
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||||
|
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||||
|
|
||||||
|
const nameParts = name.split(' ');
|
||||||
|
const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
initials,
|
||||||
|
inboundCalls: inbound,
|
||||||
|
outboundCalls: outbound,
|
||||||
|
missedCalls: missed,
|
||||||
|
totalCalls: total,
|
||||||
|
avgHandleTime: avgHandle,
|
||||||
|
appointmentsBooked: booked,
|
||||||
|
conversionRate: conversion,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.totalCalls - a.totalCalls);
|
||||||
|
}, [filteredCalls]);
|
||||||
|
|
||||||
|
// Missed call queue (recent missed calls)
|
||||||
|
const missedCallQueue = useMemo(() => {
|
||||||
|
return filteredCalls
|
||||||
|
.filter((c) => c.callStatus === 'MISSED')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [filteredCalls]);
|
||||||
|
|
||||||
|
const formatCallerPhone = (call: Call): string => {
|
||||||
|
if (!call.callerNumber || call.callerNumber.length === 0) return 'Unknown';
|
||||||
|
const first = call.callerNumber[0];
|
||||||
|
return `${first.callingCode} ${first.number}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeSince = (dateStr: string | null): string => {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supervisor AI quick prompts
|
||||||
|
const supervisorQuickPrompts = [
|
||||||
|
{ label: 'Top conversions', template: 'Which agents have the highest conversion rates this week?' },
|
||||||
|
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
|
||||||
|
{ label: 'Missed callback risks', template: 'Which missed calls have been waiting the longest without a callback?' },
|
||||||
|
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Team Dashboard" subtitle="Global Hospital \u00b7 This Week" />
|
<TopBar title="Team Dashboard" subtitle={`Global Hospital \u00b7 ${dateRangeLabel}`} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
||||||
<TeamScoreboard leads={leads} calls={calls} agents={agents} />
|
{/* Date range filter */}
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
<div className="flex items-center justify-between">
|
||||||
<LeadFunnel leads={leads} />
|
<h2 className="text-md font-semibold text-primary">Overview</h2>
|
||||||
<SlaMetrics leads={leads} />
|
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||||
|
{(['today', 'week', 'month'] as const).map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setDateRange(range)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
||||||
|
dateRange === range
|
||||||
|
? 'bg-active text-brand-secondary'
|
||||||
|
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="Total Calls"
|
||||||
|
value={totalCalls}
|
||||||
|
icon={faPhone}
|
||||||
|
iconColor="text-fg-brand-primary"
|
||||||
|
iconBg="bg-brand-secondary"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Inbound"
|
||||||
|
value={inboundCalls}
|
||||||
|
icon={faPhoneArrowDownLeft}
|
||||||
|
iconColor="text-fg-success-primary"
|
||||||
|
iconBg="bg-success-secondary"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Outbound"
|
||||||
|
value={outboundCalls}
|
||||||
|
icon={faPhoneArrowUpRight}
|
||||||
|
iconColor="text-fg-brand-primary"
|
||||||
|
iconBg="bg-brand-secondary"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Missed"
|
||||||
|
value={missedCalls}
|
||||||
|
icon={faPhoneMissed}
|
||||||
|
iconColor="text-fg-error-primary"
|
||||||
|
iconBg="bg-error-secondary"
|
||||||
|
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Metrics Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Avg Lead Response Time"
|
||||||
|
value={avgResponseTime !== null ? `${avgResponseTime} min` : '—'}
|
||||||
|
description="Time from lead creation to first contact"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Avg Missed Callback Time"
|
||||||
|
value={missedCallbackTime !== null ? `${missedCallbackTime} min` : '—'}
|
||||||
|
description="Avg wait time for missed call callbacks"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Call to Appointment %"
|
||||||
|
value={formatPercent(callToAppointmentRate)}
|
||||||
|
description="Calls resulting in appointments"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Lead to Appointment %"
|
||||||
|
value={formatPercent(leadToAppointmentRate)}
|
||||||
|
description="Leads converted to appointments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Performance Table + Missed Call Queue */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||||
|
{/* Agent Performance Table */}
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
<TableCard.Root size="sm">
|
||||||
|
<TableCard.Header
|
||||||
|
title="Agent Performance"
|
||||||
|
badge={agentPerformance.length}
|
||||||
|
description="Call metrics by agent"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-tertiary">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : agentPerformance.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||||
|
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-tertiary">No agent data available</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="AGENT" />
|
||||||
|
<Table.Head label="INBOUND" />
|
||||||
|
<Table.Head label="OUTBOUND" />
|
||||||
|
<Table.Head label="MISSED" />
|
||||||
|
<Table.Head label="AVG HANDLE TIME" />
|
||||||
|
<Table.Head label="CONVERSION %" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={agentPerformance}>
|
||||||
|
{(agent) => (
|
||||||
|
<Table.Row id={agent.id}>
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar size="sm" initials={agent.initials} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-tertiary">
|
||||||
|
{agent.totalCalls} total calls
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm font-medium text-success-primary">{agent.inboundCalls}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm font-medium text-brand-secondary">{agent.outboundCalls}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{agent.missedCalls > 0 ? (
|
||||||
|
<Badge size="sm" color="error">{agent.missedCalls}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-tertiary">0</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-secondary">
|
||||||
|
{formatDuration(agent.avgHandleTime)}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={agent.conversionRate >= 30 ? 'success' : agent.conversionRate >= 15 ? 'warning' : 'gray'}
|
||||||
|
>
|
||||||
|
{formatPercent(agent.conversionRate)}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TableCard.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missed Call Queue */}
|
||||||
|
<div className="xl:col-span-1">
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
|
||||||
|
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faPhoneMissed} className="size-4 text-fg-error-primary" />
|
||||||
|
<h3 className="text-md font-semibold text-primary">Missed Call Queue</h3>
|
||||||
|
</div>
|
||||||
|
{missedCalls > 0 && (
|
||||||
|
<Badge size="sm" color="error">{missedCalls}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
|
{missedCallQueue.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
||||||
|
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-tertiary">No missed calls</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-secondary">
|
||||||
|
{missedCallQueue.map((call) => (
|
||||||
|
<li key={call.id} className="flex items-center justify-between px-5 py-3 hover:bg-primary_hover transition duration-100 ease-linear">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{formatCallerPhone(call)}
|
||||||
|
</span>
|
||||||
|
{call.leadName && (
|
||||||
|
<span className="text-xs text-tertiary">{call.leadName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-tertiary whitespace-nowrap">
|
||||||
|
{getTimeSince(call.startedAt)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Assistant Section */}
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<FontAwesomeIcon icon={faChartMixed} className="size-4 text-fg-brand-primary" />
|
||||||
|
<h3 className="text-md font-semibold text-primary">Supervisor AI Assistant</h3>
|
||||||
|
</div>
|
||||||
|
<div className="h-[350px]">
|
||||||
|
<AiChatPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CampaignRoiCards campaigns={campaigns} />
|
|
||||||
<IntegrationHealth sources={ingestionSources} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
FOLLOW_UPS_QUERY,
|
FOLLOW_UPS_QUERY,
|
||||||
LEAD_ACTIVITIES_QUERY,
|
LEAD_ACTIVITIES_QUERY,
|
||||||
CALLS_QUERY,
|
CALLS_QUERY,
|
||||||
|
PATIENTS_QUERY,
|
||||||
} from '@/lib/queries';
|
} from '@/lib/queries';
|
||||||
import {
|
import {
|
||||||
transformLeads,
|
transformLeads,
|
||||||
@@ -16,9 +17,10 @@ import {
|
|||||||
transformFollowUps,
|
transformFollowUps,
|
||||||
transformLeadActivities,
|
transformLeadActivities,
|
||||||
transformCalls,
|
transformCalls,
|
||||||
|
transformPatients,
|
||||||
} from '@/lib/transforms';
|
} from '@/lib/transforms';
|
||||||
|
|
||||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource } from '@/types/entities';
|
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient } from '@/types/entities';
|
||||||
|
|
||||||
type DataContextType = {
|
type DataContextType = {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -29,6 +31,7 @@ type DataContextType = {
|
|||||||
templates: WhatsAppTemplate[];
|
templates: WhatsAppTemplate[];
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
|
patients: Patient[];
|
||||||
ingestionSources: LeadIngestionSource[];
|
ingestionSources: LeadIngestionSource[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -60,6 +63,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||||
const [calls, setCalls] = useState<Call[]>([]);
|
const [calls, setCalls] = useState<Call[]>([]);
|
||||||
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -80,13 +84,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
try {
|
try {
|
||||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||||
|
|
||||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
||||||
gql<any>(LEADS_QUERY),
|
gql<any>(LEADS_QUERY),
|
||||||
gql<any>(CAMPAIGNS_QUERY),
|
gql<any>(CAMPAIGNS_QUERY),
|
||||||
gql<any>(ADS_QUERY),
|
gql<any>(ADS_QUERY),
|
||||||
gql<any>(FOLLOW_UPS_QUERY),
|
gql<any>(FOLLOW_UPS_QUERY),
|
||||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||||
gql<any>(CALLS_QUERY),
|
gql<any>(CALLS_QUERY),
|
||||||
|
gql<any>(PATIENTS_QUERY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (leadsData) setLeads(transformLeads(leadsData));
|
if (leadsData) setLeads(transformLeads(leadsData));
|
||||||
@@ -95,6 +100,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
||||||
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||||
if (callsData) setCalls(transformCalls(callsData));
|
if (callsData) setCalls(transformCalls(callsData));
|
||||||
|
if (patientsData) setPatients(transformPatients(patientsData));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Failed to load data');
|
setError(err.message ?? 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,7 +122,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DataContext.Provider value={{
|
<DataContext.Provider value={{
|
||||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources,
|
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, patients, ingestionSources,
|
||||||
loading, error,
|
loading, error,
|
||||||
updateLead, addCall, refresh: fetchData,
|
updateLead, addCall, refresh: fetchData,
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -278,6 +278,22 @@ export type Call = {
|
|||||||
leadService?: string;
|
leadService?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Patient domain
|
||||||
|
export type PatientStatus = 'ACTIVE' | 'INACTIVE';
|
||||||
|
export type PatientGender = 'MALE' | 'FEMALE' | 'OTHER';
|
||||||
|
export type PatientType = 'OPD' | 'IPD' | 'EMERGENCY' | 'REGULAR';
|
||||||
|
|
||||||
|
export type Patient = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
fullName: { firstName: string; lastName: string } | null;
|
||||||
|
phones: { primaryPhoneNumber: string } | null;
|
||||||
|
emails: { primaryEmail: string } | null;
|
||||||
|
dateOfBirth: string | null;
|
||||||
|
gender: PatientGender | null;
|
||||||
|
patientType: PatientType | null;
|
||||||
|
};
|
||||||
|
|
||||||
// Lead Ingestion Source domain
|
// Lead Ingestion Source domain
|
||||||
export type IntegrationStatus = 'ACTIVE' | 'WARNING' | 'ERROR' | 'DISABLED';
|
export type IntegrationStatus = 'ACTIVE' | 'WARNING' | 'ERROR' | 'DISABLED';
|
||||||
export type AuthStatus = 'VALID' | 'EXPIRING_SOON' | 'EXPIRED' | 'NOT_CONFIGURED';
|
export type AuthStatus = 'VALID' | 'EXPIRING_SOON' | 'EXPIRED' | 'NOT_CONFIGURED';
|
||||||
|
|||||||
Reference in New Issue
Block a user