diff --git a/src/components/leads/aging-widget.tsx b/src/components/leads/aging-widget.tsx
new file mode 100644
index 0000000..3eed4db
--- /dev/null
+++ b/src/components/leads/aging-widget.tsx
@@ -0,0 +1,71 @@
+import { cx } from '@/utils/cx';
+import { daysAgoFromNow } from '@/lib/format';
+import type { Lead } from '@/types/entities';
+
+interface AgingWidgetProps {
+ leads: Lead[];
+}
+
+type AgingBracket = {
+ label: string;
+ color: string;
+ barColor: string;
+ count: number;
+};
+
+export const AgingWidget = ({ leads }: AgingWidgetProps) => {
+ const leadsWithAge = leads.filter((l) => l.createdAt !== null);
+ const total = leadsWithAge.length || 1;
+
+ const freshCount = leadsWithAge.filter((l) => daysAgoFromNow(l.createdAt!) < 2).length;
+ const warmCount = leadsWithAge.filter((l) => {
+ const days = daysAgoFromNow(l.createdAt!);
+ return days >= 2 && days <= 5;
+ }).length;
+ const coldCount = leadsWithAge.filter((l) => daysAgoFromNow(l.createdAt!) > 5).length;
+
+ const brackets: AgingBracket[] = [
+ {
+ label: 'Fresh (<2 days)',
+ color: 'text-success-primary',
+ barColor: 'bg-success-solid',
+ count: freshCount,
+ },
+ {
+ label: 'Warm (2-5 days)',
+ color: 'text-warning-primary',
+ barColor: 'bg-warning-solid',
+ count: warmCount,
+ },
+ {
+ label: 'Cold (>5 days)',
+ color: 'text-error-primary',
+ barColor: 'bg-error-solid',
+ count: coldCount,
+ },
+ ];
+
+ return (
+
+
Lead Aging
+
+ {brackets.map((bracket) => (
+
+
+ {bracket.label}
+
+ {bracket.count}
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/leads/alerts-widget.tsx b/src/components/leads/alerts-widget.tsx
new file mode 100644
index 0000000..f28fb50
--- /dev/null
+++ b/src/components/leads/alerts-widget.tsx
@@ -0,0 +1,30 @@
+import { daysAgoFromNow } from '@/lib/format';
+import type { Lead } from '@/types/entities';
+
+interface AlertsWidgetProps {
+ leads: Lead[];
+}
+
+export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
+ const agingCount = leads.filter(
+ (l) =>
+ l.leadStatus === 'NEW' &&
+ l.createdAt !== null &&
+ daysAgoFromNow(l.createdAt) > 5,
+ ).length;
+
+ if (agingCount === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {agingCount} leads aging > 5 days
+
+
+ These leads haven't been contacted and are at risk of going cold.
+
+
+ );
+};
diff --git a/src/components/leads/followup-widget.tsx b/src/components/leads/followup-widget.tsx
new file mode 100644
index 0000000..9e7375b
--- /dev/null
+++ b/src/components/leads/followup-widget.tsx
@@ -0,0 +1,64 @@
+import { Button } from '@/components/base/buttons/button';
+import { cx } from '@/utils/cx';
+import { formatShortDate } from '@/lib/format';
+import type { FollowUp } from '@/types/entities';
+
+interface FollowupWidgetProps {
+ overdue: FollowUp[];
+ upcoming: FollowUp[];
+}
+
+export const FollowupWidget = ({ overdue, upcoming }: FollowupWidgetProps) => {
+ const items = [...overdue, ...upcoming].slice(0, 4);
+
+ if (items.length === 0) {
+ return null;
+ }
+
+ return (
+
+
Upcoming Follow-ups
+
+ {items.map((item) => {
+ const isOverdue = overdue.some((o) => o.id === item.id);
+
+ return (
+
+
+ {item.scheduledAt
+ ? formatShortDate(item.scheduledAt)
+ : 'No date'}
+ {isOverdue && ' — Overdue'}
+
+
+ {item.description ?? item.followUpType ?? 'Follow-up'}
+
+ {(item.patientName || item.patientPhone) && (
+
+ {item.patientName}
+ {item.patientPhone && ` · ${item.patientPhone}`}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/src/components/leads/kpi-cards.tsx b/src/components/leads/kpi-cards.tsx
new file mode 100644
index 0000000..dfa766b
--- /dev/null
+++ b/src/components/leads/kpi-cards.tsx
@@ -0,0 +1,93 @@
+import { cx } from '@/utils/cx';
+import type { Lead } from '@/types/entities';
+
+interface KpiCardsProps {
+ leads: Lead[];
+}
+
+type KpiCard = {
+ label: string;
+ value: number;
+ delta: string;
+ deltaColor: string;
+ isHero: boolean;
+};
+
+export const KpiCards = ({ leads }: KpiCardsProps) => {
+ const newCount = leads.filter((l) => l.leadStatus === 'NEW').length;
+ const assignedCount = leads.filter((l) => l.assignedAgent !== null).length;
+ const contactedCount = leads.filter((l) => l.leadStatus === 'CONTACTED').length;
+ const convertedCount = leads.filter((l) => l.leadStatus === 'CONVERTED').length;
+
+ const cards: KpiCard[] = [
+ {
+ label: 'New Leads Today',
+ value: newCount,
+ delta: '+12% vs yesterday',
+ deltaColor: 'text-success-primary',
+ isHero: true,
+ },
+ {
+ label: 'Assigned to CC',
+ value: assignedCount,
+ delta: '85% assigned',
+ deltaColor: 'text-brand-secondary',
+ isHero: false,
+ },
+ {
+ label: 'Contacted',
+ value: contactedCount,
+ delta: '+8% vs yesterday',
+ deltaColor: 'text-success-primary',
+ isHero: false,
+ },
+ {
+ label: 'Converted',
+ value: convertedCount,
+ delta: '+3 this week',
+ deltaColor: 'text-warning-primary',
+ isHero: false,
+ },
+ ];
+
+ return (
+
+ {cards.map((card) => (
+
+
+ {card.label}
+
+
+ {card.value}
+
+
+ {card.delta}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/leads/lead-card.tsx b/src/components/leads/lead-card.tsx
new file mode 100644
index 0000000..117676e
--- /dev/null
+++ b/src/components/leads/lead-card.tsx
@@ -0,0 +1,115 @@
+import { Avatar } from '@/components/base/avatar/avatar';
+import { Badge } from '@/components/base/badges/badges';
+import { Button } from '@/components/base/buttons/button';
+import { LeadStatusBadge } from '@/components/shared/status-badge';
+import { SourceTag } from '@/components/shared/source-tag';
+import { AgeIndicator } from '@/components/shared/age-indicator';
+import { formatPhone, getInitials } from '@/lib/format';
+import { cx } from '@/utils/cx';
+import type { Lead } from '@/types/entities';
+
+interface LeadCardProps {
+ lead: Lead;
+ onAssign: (lead: Lead) => void;
+ onMessage: (lead: Lead) => void;
+ onMarkSpam: (lead: Lead) => void;
+ onMerge: (lead: Lead) => void;
+}
+
+const sourceLabelMap: Record = {
+ FACEBOOK_AD: 'Facebook',
+ INSTAGRAM: 'Instagram',
+ GOOGLE_AD: 'Google',
+ GOOGLE_MY_BUSINESS: 'GMB',
+ WHATSAPP: 'WhatsApp',
+ WEBSITE: 'Website',
+ REFERRAL: 'Referral',
+ WALK_IN: 'Walk-in',
+ PHONE: 'Phone',
+ OTHER: 'Other',
+};
+
+export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge }: LeadCardProps) => {
+ const firstName = lead.contactName?.firstName ?? '';
+ const lastName = lead.contactName?.lastName ?? '';
+ const name = `${firstName} ${lastName}`.trim() || 'Unknown';
+ const initials = firstName && lastName ? getInitials(firstName, lastName) : '??';
+ const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '';
+ const sourceLabel = lead.leadSource ? sourceLabelMap[lead.leadSource] ?? lead.leadSource : '';
+ const isSpam = (lead.spamScore ?? 0) >= 60;
+ const isDuplicate = lead.isDuplicate === true;
+
+ return (
+
+ {/* Avatar */}
+
+
+ {/* Middle content */}
+
+
+ {name}
+ {lead.leadStatus && }
+ {isSpam && (
+
+ {lead.spamScore}% Spam Risk
+
+ )}
+ {isDuplicate && (
+
+ Duplicate
+
+ )}
+
+
+ {phone}
+ {lead.interestedService && ` · ${lead.interestedService}`}
+ {sourceLabel && ` · via ${sourceLabel}`}
+ {lead.utmCampaign && ` · ${lead.utmCampaign}`}
+
+
+
+ {/* Age + Source */}
+
+ {lead.createdAt &&
}
+ {lead.leadSource &&
}
+
+
+ {/* Action buttons */}
+
+ {isSpam ? (
+ <>
+
+
+ >
+ ) : isDuplicate ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/src/components/leads/source-grid.tsx b/src/components/leads/source-grid.tsx
new file mode 100644
index 0000000..fec750a
--- /dev/null
+++ b/src/components/leads/source-grid.tsx
@@ -0,0 +1,139 @@
+import { cx } from '@/utils/cx';
+import type { Lead, LeadSource } from '@/types/entities';
+
+interface SourceGridProps {
+ leads: Lead[];
+ onSourceFilter: (source: LeadSource | null) => void;
+ activeSource: LeadSource | null;
+}
+
+type SourceConfig = {
+ source: LeadSource;
+ label: string;
+ icon: string;
+ iconBg: string;
+ iconText: string;
+ countColor: string;
+ delta: string;
+};
+
+const sourceConfigs: SourceConfig[] = [
+ {
+ source: 'FACEBOOK_AD',
+ label: 'Facebook Ads',
+ icon: 'f',
+ iconBg: 'bg-utility-blue-50',
+ iconText: 'text-utility-blue-700',
+ countColor: 'text-utility-blue-700',
+ delta: '+4',
+ },
+ {
+ source: 'GOOGLE_AD',
+ label: 'Google Ads',
+ icon: 'G',
+ iconBg: 'bg-utility-success-50',
+ iconText: 'text-utility-success-700',
+ countColor: 'text-utility-success-700',
+ delta: '+2',
+ },
+ {
+ source: 'INSTAGRAM',
+ label: 'Instagram',
+ icon: '@',
+ iconBg: 'bg-utility-pink-50',
+ iconText: 'text-utility-pink-700',
+ countColor: 'text-utility-pink-700',
+ delta: '+1',
+ },
+ {
+ source: 'GOOGLE_MY_BUSINESS',
+ label: 'Google My Business',
+ icon: 'G',
+ iconBg: 'bg-utility-blue-light-50',
+ iconText: 'text-utility-blue-light-700',
+ countColor: 'text-utility-blue-light-700',
+ delta: '+3',
+ },
+ {
+ source: 'REFERRAL',
+ label: 'Referrals',
+ icon: 'R',
+ iconBg: 'bg-utility-purple-50',
+ iconText: 'text-utility-purple-700',
+ countColor: 'text-utility-purple-700',
+ delta: '+2',
+ },
+ {
+ source: 'WALK_IN',
+ label: 'Walk-ins',
+ icon: 'W',
+ iconBg: 'bg-utility-orange-50',
+ iconText: 'text-utility-orange-700',
+ countColor: 'text-utility-orange-700',
+ delta: '0',
+ },
+];
+
+export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
+ const countBySource = (source: LeadSource): number =>
+ leads.filter((l) => l.leadSource === source).length;
+
+ const handleClick = (source: LeadSource) => {
+ if (activeSource === source) {
+ onSourceFilter(null);
+ } else {
+ onSourceFilter(source);
+ }
+ };
+
+ return (
+
+ {sourceConfigs.map((config) => {
+ const count = countBySource(config.source);
+ const isActive = activeSource === config.source;
+
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/src/pages/lead-workspace.tsx b/src/pages/lead-workspace.tsx
index cddaca5..6450d2b 100644
--- a/src/pages/lead-workspace.tsx
+++ b/src/pages/lead-workspace.tsx
@@ -1,11 +1,91 @@
-import { TopBar } from "@/components/layout/top-bar";
+import { useState } from 'react';
+
+import { Button } from '@/components/base/buttons/button';
+import { TopBar } from '@/components/layout/top-bar';
+import { KpiCards } from '@/components/leads/kpi-cards';
+import { SourceGrid } from '@/components/leads/source-grid';
+import { LeadCard } from '@/components/leads/lead-card';
+import { AgingWidget } from '@/components/leads/aging-widget';
+import { FollowupWidget } from '@/components/leads/followup-widget';
+import { AlertsWidget } from '@/components/leads/alerts-widget';
+import { useLeads } from '@/hooks/use-leads';
+import { useFollowUps } from '@/hooks/use-follow-ups';
+import type { LeadSource } from '@/types/entities';
export const LeadWorkspacePage = () => {
+ const [sourceFilter, setSourceFilter] = useState(null);
+
+ const { leads, total } = useLeads({ source: sourceFilter ?? undefined, status: 'NEW' });
+ const { leads: allLeads } = useLeads();
+ const { overdue, upcoming } = useFollowUps();
+
+ const displayLeads = leads.slice(0, 10);
+
+ const handleAssign = () => {
+ // placeholder
+ };
+
+ const handleMessage = () => {
+ // placeholder
+ };
+
+ const handleMarkSpam = () => {
+ // placeholder
+ };
+
+ const handleMerge = () => {
+ // placeholder
+ };
+
return (
-
-
Lead Workspace — coming soon
+
+ {/* Main content */}
+
+
+
+
+
+
Lead Sources
+ Click to filter
+
+
+
+
+
+
+
New Leads
+
+
+
+ {displayLeads.map((lead) => (
+
+ ))}
+ {displayLeads.length === 0 && (
+
+ No leads match the current filters.
+
+ )}
+
+
+
+
+ {/* Right sidebar */}
+
);