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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user