From e9ac6e598ac4391882b7144ad39497d3b72f4fbe Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 16 Mar 2026 18:28:00 +0530 Subject: [PATCH] feat: build Admin Team Dashboard with scoreboard, funnel, SLA, ROI, and integration health Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/admin/campaign-roi-cards.tsx | 93 ++++++++++++++++ src/components/admin/integration-health.tsx | 114 ++++++++++++++++++++ src/components/admin/lead-funnel.tsx | 83 ++++++++++++++ src/components/admin/sla-metrics.tsx | 114 ++++++++++++++++++++ src/components/admin/team-scoreboard.tsx | 109 +++++++++++++++++++ src/pages/team-dashboard.tsx | 25 ++++- 6 files changed, 533 insertions(+), 5 deletions(-) create mode 100644 src/components/admin/campaign-roi-cards.tsx create mode 100644 src/components/admin/integration-health.tsx create mode 100644 src/components/admin/lead-funnel.tsx create mode 100644 src/components/admin/sla-metrics.tsx create mode 100644 src/components/admin/team-scoreboard.tsx diff --git a/src/components/admin/campaign-roi-cards.tsx b/src/components/admin/campaign-roi-cards.tsx new file mode 100644 index 0000000..c3f4dbd --- /dev/null +++ b/src/components/admin/campaign-roi-cards.tsx @@ -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 ( +
+

Campaign ROI

+
+ {sorted.map((campaign) => ( +
+
+ + + {campaign.campaignName} + +
+ +
+ {campaign.leadCount ?? 0} leads + | + {campaign.convertedCount ?? 0} converted +
+ +
+ + {campaign.cac === Infinity + ? '—' + : formatCurrency(campaign.cac)} + + CAC +
+ +
+
+
+ + {Math.round(campaign.budgetProgress * 100)}% budget used + +
+ ))} +
+ + + View All Campaigns → + +
+ ); +}; diff --git a/src/components/admin/integration-health.tsx b/src/components/admin/integration-health.tsx new file mode 100644 index 0000000..f36c157 --- /dev/null +++ b/src/components/admin/integration-health.tsx @@ -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 = { + ACTIVE: 'border-secondary', + WARNING: 'border-warning', + ERROR: 'border-error', + DISABLED: 'border-secondary', +}; + +const statusBadgeColorMap: Record = { + ACTIVE: 'success', + WARNING: 'warning', + ERROR: 'error', + DISABLED: 'gray', +}; + +const authBadgeColorMap: Record = { + 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 ( +
+

Integration Health

+ +
+ {sources.map((source) => { + const status = source.integrationStatus ?? 'DISABLED'; + const authStatus = source.authStatus ?? 'NOT_CONFIGURED'; + const showAuthBadge = authStatus !== 'VALID'; + + return ( +
+
+ + {source.sourceName} + + + {status} + +
+ +

+ {source.leadsReceivedLast24h ?? 0} leads in 24h +

+ + {source.lastSuccessfulSyncAt && ( +

+ Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)} +

+ )} + + {showAuthBadge && ( +
+ + Auth: {authStatus.replace(/_/g, ' ')} + +
+ )} + + {(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && ( +

+ {source.lastErrorMessage} +

+ )} +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/admin/lead-funnel.tsx b/src/components/admin/lead-funnel.tsx new file mode 100644 index 0000000..db22e94 --- /dev/null +++ b/src/components/admin/lead-funnel.tsx @@ -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 ( +
+

Lead Funnel · This Week

+ +
+ {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 ( +
+
+ {stage.label} +
+ {stage.count} + {conversionRate !== null && ( + ({conversionRate}%) + )} +
+
+
+
+
+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/admin/sla-metrics.tsx b/src/components/admin/sla-metrics.tsx new file mode 100644 index 0000000..b843104 --- /dev/null +++ b/src/components/admin/sla-metrics.tsx @@ -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 ( +
+

Response SLA

+ +
+ + {metrics.avgHours.toFixed(1)}h + +
+ Target: {SLA_TARGET_HOURS}h + +
+
+ + + {status.label} + + +
+
+ SLA Compliance + {Math.round(metrics.slaPercent)}% +
+
+
= 80 + ? 'bg-success-500' + : metrics.slaPercent >= 60 + ? 'bg-warning-500' + : 'bg-error-500', + )} + style={{ width: `${metrics.slaPercent}%` }} + /> +
+ + {metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%) + +
+
+ ); +}; diff --git a/src/components/admin/team-scoreboard.tsx b/src/components/admin/team-scoreboard.tsx new file mode 100644 index 0000000..60683db --- /dev/null +++ b/src/components/admin/team-scoreboard.tsx @@ -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 ( +
+ {agentStats.map(({ agent, leadsProcessed, callsMade, appointmentsBooked }) => { + const isBest = agent.id === bestPerformerId; + + return ( +
+
+ +
+ {agent.name} + + {agent.isOnShift ? 'On Shift' : 'Off Shift'} + +
+ {isBest && ( + Top + )} +
+ +
+
+ Leads + {leadsProcessed} +
+
+ Calls + {callsMade} +
+
+ Appointments + {appointmentsBooked} +
+
+ Avg Response + + {agent.avgResponseHours ?? '—'}h + +
+
+
+ ); + })} +
+ ); +}; diff --git a/src/pages/team-dashboard.tsx b/src/pages/team-dashboard.tsx index 4cb72d6..88cb230 100644 --- a/src/pages/team-dashboard.tsx +++ b/src/pages/team-dashboard.tsx @@ -1,14 +1,29 @@ 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 = () => { + const { leads } = useLeads(); + const { campaigns } = useCampaigns(); + const { calls, agents, ingestionSources } = useData(); + return (
- -
-
-

Team Dashboard

-

Coming soon — team performance metrics and management tools.

+ +
+ +
+ +
+ +
);