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:
2026-03-16 18:28:00 +05:30
parent 26c352e2cc
commit e9ac6e598a
6 changed files with 533 additions and 5 deletions

View File

@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { formatCurrency } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
interface CampaignRoiCardsProps {
campaigns: Campaign[];
}
type CampaignWithCac = Campaign & {
cac: number;
conversionRate: number;
budgetProgress: number;
};
export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
const sorted = useMemo((): CampaignWithCac[] => {
return campaigns
.map((campaign) => {
const spent = campaign.amountSpent?.amountMicros ?? 0;
const converted = campaign.convertedCount ?? 0;
const leadCount = campaign.leadCount ?? 0;
const budgetMicros = campaign.budget?.amountMicros ?? 1;
const cac = converted > 0 ? spent / converted : Infinity;
const conversionRate = leadCount > 0 ? converted / leadCount : 0;
const budgetProgress = budgetMicros > 0 ? spent / budgetMicros : 0;
return { ...campaign, cac, conversionRate, budgetProgress };
})
.sort((a, b) => a.cac - b.cac);
}, [campaigns]);
const getHealthColor = (rate: number): string => {
if (rate >= 0.1) return 'bg-success-500';
if (rate >= 0.05) return 'bg-warning-500';
return 'bg-error-500';
};
return (
<div className="space-y-3">
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
<div className="flex gap-4 overflow-x-auto pb-1">
{sorted.map((campaign) => (
<div
key={campaign.id}
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
>
<div className="flex items-center gap-2">
<span
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
/>
<span className="truncate text-sm font-semibold text-primary">
{campaign.campaignName}
</span>
</div>
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
<span>{campaign.leadCount ?? 0} leads</span>
<span className="text-quaternary">|</span>
<span>{campaign.convertedCount ?? 0} converted</span>
</div>
<div className="mt-2">
<span className="text-sm font-bold text-primary">
{campaign.cac === Infinity
? '—'
: formatCurrency(campaign.cac)}
</span>
<span className="ml-1 text-xs text-tertiary">CAC</span>
</div>
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
<div
className="h-full rounded-full bg-brand-solid"
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
/>
</div>
<span className="mt-1 block text-xs text-quaternary">
{Math.round(campaign.budgetProgress * 100)}% budget used
</span>
</div>
))}
</div>
<Link to="/campaigns" className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover">
View All Campaigns &rarr;
</Link>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
interface IntegrationHealthProps {
sources: LeadIngestionSource[];
}
const statusBorderMap: Record<IntegrationStatus, string> = {
ACTIVE: 'border-secondary',
WARNING: 'border-warning',
ERROR: 'border-error',
DISABLED: 'border-secondary',
};
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
ACTIVE: 'success',
WARNING: 'warning',
ERROR: 'error',
DISABLED: 'gray',
};
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
VALID: 'success',
EXPIRING_SOON: 'warning',
EXPIRED: 'error',
NOT_CONFIGURED: 'gray',
};
function formatRelativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
return (
<div className="rounded-2xl border border-secondary bg-primary p-5">
<h3 className="text-sm font-bold text-primary">Integration Health</h3>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{sources.map((source) => {
const status = source.integrationStatus ?? 'DISABLED';
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
const showAuthBadge = authStatus !== 'VALID';
return (
<div
key={source.id}
className={cx(
'rounded-xl border bg-primary p-4',
statusBorderMap[status],
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-primary">
{source.sourceName}
</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={statusBadgeColorMap[status]}
>
{status}
</BadgeWithDot>
</div>
<p className="mt-2 text-xs text-tertiary">
{source.leadsReceivedLast24h ?? 0} leads in 24h
</p>
{source.lastSuccessfulSyncAt && (
<p className="mt-0.5 text-xs text-quaternary">
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
</p>
)}
{showAuthBadge && (
<div className="mt-2">
<BadgeWithDot
size="sm"
type="pill-color"
color={authBadgeColorMap[authStatus]}
>
Auth: {authStatus.replace(/_/g, ' ')}
</BadgeWithDot>
</div>
)}
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
<p
className={cx(
'mt-2 text-xs',
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
)}
>
{source.lastErrorMessage}
</p>
)}
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
interface LeadFunnelProps {
leads: Lead[];
}
type FunnelStage = {
label: string;
count: number;
color: string;
};
export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
const stages = useMemo((): FunnelStage[] => {
const total = leads.length;
const contacted = leads.filter((lead) =>
lead.leadStatus === 'CONTACTED' ||
lead.leadStatus === 'QUALIFIED' ||
lead.leadStatus === 'NURTURING' ||
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
).length;
const appointmentSet = leads.filter((lead) =>
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
).length;
const converted = leads.filter((lead) =>
lead.leadStatus === 'CONVERTED',
).length;
return [
{ label: 'Generated', count: total, color: 'bg-brand-600' },
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
{ label: 'Converted', count: converted, color: 'bg-success-500' },
];
}, [leads]);
const maxCount = stages[0]?.count || 1;
return (
<div className="rounded-2xl border border-secondary bg-primary p-5">
<h3 className="text-sm font-bold text-primary">Lead Funnel &middot; This Week</h3>
<div className="mt-4 space-y-3">
{stages.map((stage, index) => {
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
const previousCount = index > 0 ? stages[index - 1].count : null;
const conversionRate =
previousCount !== null && previousCount > 0
? ((stage.count / previousCount) * 100).toFixed(0)
: null;
return (
<div key={stage.label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-secondary">{stage.label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">{stage.count}</span>
{conversionRate !== null && (
<span className="text-xs text-tertiary">({conversionRate}%)</span>
)}
</div>
</div>
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
<div
className={cx('h-full rounded-md transition-all', stage.color)}
style={{ width: `${Math.max(widthPercent, 2)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
};

View 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>
);
};

View File

@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import { Avatar } from '@/components/base/avatar/avatar';
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { Lead, Call, Agent } from '@/types/entities';
interface TeamScoreboardProps {
leads: Lead[];
calls: Call[];
agents: Agent[];
}
type AgentStats = {
agent: Agent;
leadsProcessed: number;
callsMade: number;
appointmentsBooked: number;
};
export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) => {
const agentStats = useMemo((): AgentStats[] => {
return agents.map((agent) => {
const agentName = agent.name;
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
const agentCalls = calls.filter((call) => call.agentName === agentName);
const callsMade = agentCalls.length;
const appointmentsBooked = agentCalls.filter(
(call) => call.disposition === 'APPOINTMENT_BOOKED',
).length;
return { agent, leadsProcessed, callsMade, appointmentsBooked };
});
}, [leads, calls, agents]);
const bestPerformerId = useMemo(() => {
let bestId = '';
let maxAppointments = -1;
for (const stat of agentStats) {
if (stat.appointmentsBooked > maxAppointments) {
maxAppointments = stat.appointmentsBooked;
bestId = stat.agent.id;
}
}
return bestId;
}, [agentStats]);
return (
<div className="flex gap-4 overflow-x-auto">
{agentStats.map(({ agent, leadsProcessed, callsMade, appointmentsBooked }) => {
const isBest = agent.id === bestPerformerId;
return (
<div
key={agent.id}
className={cx(
'flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5',
isBest && 'ring-2 ring-success-600',
)}
>
<div className="flex items-center gap-3">
<Avatar
initials={agent.initials ?? undefined}
size="md"
status={agent.isOnShift ? 'online' : 'offline'}
/>
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold text-primary">{agent.name}</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={agent.isOnShift ? 'success' : 'gray'}
>
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
</BadgeWithDot>
</div>
{isBest && (
<span className="text-xs font-medium text-success-primary">Top</span>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col">
<span className="text-xs text-tertiary">Leads</span>
<span className="text-lg font-bold text-primary">{leadsProcessed}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-tertiary">Calls</span>
<span className="text-lg font-bold text-primary">{callsMade}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-tertiary">Appointments</span>
<span className="text-lg font-bold text-primary">{appointmentsBooked}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-tertiary">Avg Response</span>
<span className="text-lg font-bold text-primary">
{agent.avgResponseHours ?? '—'}h
</span>
</div>
</div>
</div>
);
})}
</div>
);
};

View File

@@ -1,14 +1,29 @@
import { TopBar } from '@/components/layout/top-bar'; 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 { useData } from '@/providers/data-provider';
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { leads } = useLeads();
const { campaigns } = useCampaigns();
const { calls, agents, ingestionSources } = useData();
return ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<TopBar title="Team Dashboard" subtitle="Team performance overview" /> <TopBar title="Team Dashboard" subtitle="Ramaiah Memorial Hospital \u00b7 This Week" />
<div className="flex flex-1 items-center justify-center p-7"> <div className="flex-1 overflow-y-auto p-7 space-y-6">
<div className="flex flex-col items-center gap-2 text-center"> <TeamScoreboard leads={leads} calls={calls} agents={agents} />
<h2 className="text-display-xs font-bold text-primary">Team Dashboard</h2> <div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<p className="text-sm text-tertiary">Coming soon team performance metrics and management tools.</p> <LeadFunnel leads={leads} />
<SlaMetrics leads={leads} />
</div> </div>
<CampaignRoiCards campaigns={campaigns} />
<IntegrationHealth sources={ingestionSources} />
</div> </div>
</div> </div>
); );