mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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:
93
src/components/admin/campaign-roi-cards.tsx
Normal file
93
src/components/admin/campaign-roi-cards.tsx
Normal 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 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
src/components/admin/integration-health.tsx
Normal file
114
src/components/admin/integration-health.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
83
src/components/admin/lead-funnel.tsx
Normal file
83
src/components/admin/lead-funnel.tsx
Normal 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 · 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
109
src/components/admin/team-scoreboard.tsx
Normal file
109
src/components/admin/team-scoreboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user