feat: My Performance page + logout modal + sidebar cleanup

- My Performance page with KPI cards, ECharts, DatePicker, time utilization
- Sidecar: agent summary + AHT + performance aggregation endpoint
- Logout confirmation modal
- Removed Patients from CC agent nav

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 13:40:37 +05:30
parent 5ccfa9bca8
commit 721c2879ec
4 changed files with 669 additions and 0 deletions

View File

@@ -0,0 +1,322 @@
import { useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhoneVolume, faPhoneArrowDown, faCalendarCheck, faClock,
faPercent, faRightToBracket,
} from '@fortawesome/pro-duotone-svg-icons';
import { getLocalTimeZone, parseDate, today as todayDate } from '@internationalized/date';
import type { DateValue } from 'react-aria-components';
import { DatePicker } from '@/components/application/date-picker/date-picker';
import { TopBar } from '@/components/layout/top-bar';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
type PerformanceData = {
date: string;
calls: { total: number; inbound: number; outbound: number; answered: number; missed: number };
avgTalkTimeSec: number;
avgHandlingTime: string;
conversionRate: number;
appointmentsBooked: number;
timeUtilization: {
totalLoginDuration: string;
totalBusyTime: string;
totalIdleTime: string;
totalPauseTime: string;
totalWrapupTime: string;
totalDialTime: string;
} | null;
dispositions: Record<string, number>;
};
const parseTime = (timeStr: string): number => {
const parts = timeStr.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
const formatDuration = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m`;
};
const BRAND = {
blue600: 'rgb(32, 96, 160)',
blue500: 'rgb(56, 120, 180)',
blue400: 'rgb(96, 150, 200)',
blue300: 'rgb(138, 180, 220)',
success: 'rgb(23, 178, 106)',
warning: 'rgb(247, 144, 9)',
error: 'rgb(240, 68, 56)',
gray400: 'rgb(164, 167, 174)',
purple: 'rgb(158, 119, 237)',
};
type KpiCardProps = {
icon: any;
iconColor: string;
label: string;
value: string | number;
subtitle?: string;
};
const KpiCard = ({ icon, iconColor, label, value, subtitle }: KpiCardProps) => (
<div className="flex flex-col gap-2 rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
<FontAwesomeIcon icon={icon} className={cx('size-4', iconColor)} />
</div>
<p className="text-2xl font-bold text-primary">{value}</p>
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
</div>
);
export const MyPerformancePage = () => {
const [data, setData] = useState<PerformanceData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedDate, setSelectedDate] = useState(() => new Date().toISOString().split('T')[0]);
useEffect(() => {
setLoading(true);
apiClient.get<PerformanceData>(`/api/ozonetel/performance?date=${selectedDate}`, { silent: true })
.then(setData)
.catch(() => setData(null))
.finally(() => setLoading(false));
}, [selectedDate]);
const now = todayDate(getLocalTimeZone());
const dateValue = selectedDate ? parseDate(selectedDate) : now;
const handleDateChange = (value: DateValue | null) => {
if (value) {
setSelectedDate(value.toString());
}
};
return (
<div className="flex flex-1 flex-col">
<TopBar title="My Performance" subtitle="Personal call center metrics" />
<div className="flex flex-1 flex-col overflow-y-auto p-6 gap-6">
{/* Date selector */}
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedDate(now.toString())}
className={cx(
'px-3 py-1.5 text-xs font-medium rounded-lg transition duration-100 ease-linear',
selectedDate === now.toString()
? 'bg-brand-solid text-white'
: 'bg-secondary text-secondary hover:bg-secondary_hover',
)}
>
Today
</button>
<button
onClick={() => setSelectedDate(now.subtract({ days: 1 }).toString())}
className={cx(
'px-3 py-1.5 text-xs font-medium rounded-lg transition duration-100 ease-linear',
selectedDate === now.subtract({ days: 1 }).toString()
? 'bg-brand-solid text-white'
: 'bg-secondary text-secondary hover:bg-secondary_hover',
)}
>
Yesterday
</button>
<DatePicker
value={dateValue}
onChange={handleDateChange}
maxValue={now}
aria-label="Select date"
/>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading performance data...</p>
</div>
) : !data ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">No performance data available for this date.</p>
</div>
) : (
<>
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<KpiCard
icon={faPhoneVolume}
iconColor="text-fg-brand-primary"
label="Total Calls"
value={data.calls.total}
subtitle={`${data.calls.inbound} in · ${data.calls.outbound} out`}
/>
<KpiCard
icon={faPhoneArrowDown}
iconColor="text-fg-success-primary"
label="Answered"
value={data.calls.answered}
subtitle={`${data.calls.missed} missed`}
/>
<KpiCard
icon={faCalendarCheck}
iconColor="text-fg-brand-primary"
label="Appts Booked"
value={data.appointmentsBooked}
/>
<KpiCard
icon={faClock}
iconColor="text-fg-warning-primary"
label="Avg Talk Time"
value={formatDuration(data.avgTalkTimeSec)}
/>
<KpiCard
icon={faPercent}
iconColor="text-fg-success-primary"
label="Conversion"
value={`${data.conversionRate}%`}
/>
<KpiCard
icon={faRightToBracket}
iconColor="text-fg-brand-primary"
label="Login Time"
value={data.timeUtilization ? formatDuration(parseTime(data.timeUtilization.totalLoginDuration)) : '—'}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Call breakdown bar chart */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-primary mb-4">Call Breakdown</h3>
<ReactECharts
option={{
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: {
type: 'category',
data: ['Inbound', 'Outbound', 'Answered', 'Missed'],
axisLabel: { fontSize: 11, color: '#667085' },
},
yAxis: {
type: 'value',
axisLabel: { fontSize: 11, color: '#667085' },
},
series: [{
type: 'bar',
data: [
{ value: data.calls.inbound, itemStyle: { color: BRAND.blue600 } },
{ value: data.calls.outbound, itemStyle: { color: BRAND.blue400 } },
{ value: data.calls.answered, itemStyle: { color: BRAND.success } },
{ value: data.calls.missed, itemStyle: { color: BRAND.error } },
],
barWidth: '50%',
itemStyle: { borderRadius: [4, 4, 0, 0] },
}],
}}
style={{ height: 240 }}
/>
</div>
{/* Disposition donut */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-primary mb-4">Disposition Breakdown</h3>
{Object.keys(data.dispositions).length === 0 ? (
<div className="flex items-center justify-center h-[240px]">
<p className="text-xs text-quaternary">No dispositions recorded</p>
</div>
) : (
<ReactECharts
option={{
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
orient: 'vertical',
right: 10,
top: 'center',
textStyle: { fontSize: 11, color: '#667085' },
},
series: [{
type: 'pie',
radius: ['45%', '70%'],
center: ['35%', '50%'],
avoidLabelOverlap: false,
label: { show: false },
data: Object.entries(data.dispositions).map(([name, value], i) => ({
name,
value,
itemStyle: {
color: [BRAND.blue600, BRAND.success, BRAND.warning, BRAND.purple, BRAND.gray400, BRAND.error][i % 6],
},
})),
}],
}}
style={{ height: 240 }}
/>
)}
</div>
</div>
{/* Time utilization */}
{data.timeUtilization && (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-primary mb-4">Time Utilization</h3>
<TimeBar utilization={data.timeUtilization} />
</div>
)}
</>
)}
</div>
</div>
);
};
const TimeBar = ({ utilization }: { utilization: NonNullable<PerformanceData['timeUtilization']> }) => {
const busy = parseTime(utilization.totalBusyTime);
const idle = parseTime(utilization.totalIdleTime);
const pause = parseTime(utilization.totalPauseTime);
const wrapup = parseTime(utilization.totalWrapupTime);
const dial = parseTime(utilization.totalDialTime);
const total = busy + idle + pause + wrapup + dial;
if (total === 0) {
return <p className="text-xs text-quaternary">No time data available</p>;
}
const segments = [
{ label: 'Busy', value: busy, color: BRAND.blue600 },
{ label: 'Dialing', value: dial, color: BRAND.blue400 },
{ label: 'Idle', value: idle, color: BRAND.gray400 },
{ label: 'Wrap-up', value: wrapup, color: BRAND.warning },
{ label: 'Pause', value: pause, color: BRAND.error },
].filter(s => s.value > 0);
return (
<div className="space-y-3">
{/* Stacked bar */}
<div className="flex h-6 rounded-lg overflow-hidden">
{segments.map(s => (
<div
key={s.label}
style={{ width: `${(s.value / total) * 100}%`, backgroundColor: s.color }}
title={`${s.label}: ${formatDuration(s.value)}`}
/>
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4">
{segments.map(s => (
<div key={s.label} className="flex items-center gap-2">
<div className="size-2.5 rounded-full" style={{ backgroundColor: s.color }} />
<span className="text-xs text-secondary">{s.label}</span>
<span className="text-xs font-semibold text-primary">{formatDuration(s.value)}</span>
<span className="text-xs text-quaternary">({Math.round((s.value / total) * 100)}%)</span>
</div>
))}
</div>
</div>
);
};