mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +00:00
feat: build Admin Team Dashboard with scoreboard, funnel, SLA, ROI, and integration health
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
114
src/components/admin/sla-metrics.tsx
Normal file
114
src/components/admin/sla-metrics.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { CheckCircle, AlertTriangle, AlertCircle } from '@untitledui/icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
interface SlaMetricsProps {
|
||||
leads: Lead[];
|
||||
}
|
||||
|
||||
const SLA_TARGET_HOURS = 2;
|
||||
|
||||
export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
|
||||
const metrics = useMemo(() => {
|
||||
const responseTimes: number[] = [];
|
||||
let withinSla = 0;
|
||||
let total = leads.length;
|
||||
|
||||
for (const lead of leads) {
|
||||
if (lead.createdAt && lead.firstContactedAt) {
|
||||
const created = new Date(lead.createdAt).getTime();
|
||||
const contacted = new Date(lead.firstContactedAt).getTime();
|
||||
const diffHours = (contacted - created) / (1000 * 60 * 60);
|
||||
responseTimes.push(diffHours);
|
||||
|
||||
if (diffHours <= SLA_TARGET_HOURS) {
|
||||
withinSla++;
|
||||
}
|
||||
}
|
||||
// Leads without firstContactedAt are counted as outside SLA
|
||||
}
|
||||
|
||||
const avgHours =
|
||||
responseTimes.length > 0
|
||||
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
|
||||
: 0;
|
||||
|
||||
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
|
||||
|
||||
return { avgHours, withinSla, total, slaPercent };
|
||||
}, [leads]);
|
||||
|
||||
const getTargetStatus = (): { icon: typeof CheckCircle; label: string; colorClass: string } => {
|
||||
const diff = metrics.avgHours - SLA_TARGET_HOURS;
|
||||
|
||||
if (diff <= 0) {
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
label: 'Below target',
|
||||
colorClass: 'text-success-primary',
|
||||
};
|
||||
}
|
||||
|
||||
if (diff <= 0.5) {
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: 'Near target',
|
||||
colorClass: 'text-warning-primary',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
label: 'Above target',
|
||||
colorClass: 'text-error-primary',
|
||||
};
|
||||
};
|
||||
|
||||
const status = getTargetStatus();
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-secondary bg-primary p-5">
|
||||
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
|
||||
|
||||
<div className="mt-4 flex items-end gap-3">
|
||||
<span className="text-display-sm font-bold text-primary">
|
||||
{metrics.avgHours.toFixed(1)}h
|
||||
</span>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
|
||||
<StatusIcon className={cx('size-4', status.colorClass)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-xs text-tertiary">
|
||||
<span>SLA Compliance</span>
|
||||
<span className="font-medium text-primary">{Math.round(metrics.slaPercent)}%</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
|
||||
<div
|
||||
className={cx(
|
||||
'h-full rounded-full transition-all',
|
||||
metrics.slaPercent >= 80
|
||||
? 'bg-success-500'
|
||||
: metrics.slaPercent >= 60
|
||||
? 'bg-warning-500'
|
||||
: 'bg-error-500',
|
||||
)}
|
||||
style={{ width: `${metrics.slaPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1.5 block text-xs text-tertiary">
|
||||
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user