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:
2026-03-19 12:26:13 +05:30
parent c3b1bfe18e
commit 4c6cae9d65
12 changed files with 1994 additions and 263 deletions

View File

@@ -75,16 +75,14 @@ export const CallDeskPage = () => {
{/* Worklist (visible when idle) */}
{!isInCall && (
<div className="rounded-xl border border-secondary bg-primary">
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
</div>
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
)}
{/* Today's calls — always visible */}

View File

@@ -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 { 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 { 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 { useAuth } from '@/providers/auth-provider';
import type { CallDisposition } from '@/types/entities';
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
const dispositionColor = (disposition: CallDisposition | null): 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' => {
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';
}
};
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
const formatDispositionLabel = (disposition: CallDisposition | null): string => {
if (!disposition) return '—';
return disposition
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const filterItems = [
{ id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' },
{ id: 'missed' as const, label: 'Missed' },
];
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 => {
if (seconds === null) return '';
const mins = Math.round(seconds / 60);
return mins === 0 ? '<1 min' : `${mins} min`;
if (seconds === null || seconds === 0) return '\u2014';
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 formatCallerNumber = (callerNumber: { number: string; callingCode: string }[] | null): string => {
if (!callerNumber || callerNumber.length === 0) return '—';
const first = callerNumber[0];
return `${first.callingCode} ${first.number}`;
const formatPhoneDisplay = (call: Call): string => {
if (call.callerNumber && call.callerNumber.length > 0) {
return formatPhone(call.callerNumber[0]);
}
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 = () => {
const { calls } = useData();
const { user } = useAuth();
const { calls, leads } = useData();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterKey>('all');
const agentCalls = calls
.filter((call) => call.agentName === user.name)
.sort((a, b) => {
// Build a map of lead names by ID for enrichment
const leadNameMap = useMemo(() => {
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 dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
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 (
<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">
{agentCalls.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="flex flex-col items-center gap-2 text-center">
<TableCard.Root size="md">
<TableCard.Header
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>
<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 className="rounded-2xl border border-secondary bg-primary overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-secondary">
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
Date / Time
</th>
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
Caller
</th>
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
Lead Name
</th>
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
Duration
</th>
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
Disposition
</th>
</tr>
</thead>
<tbody>
{agentCalls.map((call) => (
<tr key={call.id} className="border-b border-tertiary hover:bg-primary_hover transition duration-100 ease-linear">
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
{call.startedAt ? formatShortDate(call.startedAt) : '—'}
</td>
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
{formatCallerNumber(call.callerNumber)}
</td>
<td className="px-4 py-3 text-sm text-primary">
{call.leadName ?? '—'}
</td>
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
{formatDuration(call.durationSeconds)}
</td>
<td className="px-4 py-3">
{call.disposition ? (
<Badge size="sm" color={dispositionColor(call.disposition)}>
{formatDispositionLabel(call.disposition)}
</Badge>
) : (
<span className="text-sm text-tertiary"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
) : (
<Table>
<Table.Header>
<Table.Head label="TYPE" className="w-14" />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" />
<Table.Head label="AGENT" />
<Table.Head label="RECORDING" className="w-24" />
<Table.Head label="TIME" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={filteredCalls}>
{(call) => {
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
const phoneDisplay = formatPhoneDisplay(call);
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return (
<Table.Row id={call.id}>
<Table.Cell>
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
</Table.Cell>
<Table.Cell>
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
{patientName}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{phoneDisplay}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary whitespace-nowrap">
{formatDuration(call.durationSeconds)}
</span>
</Table.Cell>
<Table.Cell>
{dispositionCfg ? (
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
{dispositionCfg.label}
</Badge>
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary">
{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>
);

253
src/pages/patients.tsx Normal file
View 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
View 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>
);
};

View File

@@ -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 { TeamScoreboard } from '@/components/admin/team-scoreboard';
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 { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
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 = () => {
const { leads } = useLeads();
const { campaigns } = useCampaigns();
const { calls, agents, ingestionSources } = useData();
const { calls, leads, followUps, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week');
// 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 (
<div className="flex flex-1 flex-col">
<TopBar title="Team Dashboard" subtitle="Global Hospital \u00b7 This Week" />
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Team Dashboard" subtitle={`Global Hospital \u00b7 ${dateRangeLabel}`} />
<div className="flex-1 overflow-y-auto p-7 space-y-6">
<TeamScoreboard leads={leads} calls={calls} agents={agents} />
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<LeadFunnel leads={leads} />
<SlaMetrics leads={leads} />
{/* Date range filter */}
<div className="flex items-center justify-between">
<h2 className="text-md font-semibold text-primary">Overview</h2>
<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>
<CampaignRoiCards campaigns={campaigns} />
<IntegrationHealth sources={ingestionSources} />
</div>
</div>
);