From 1bed4b7d08d677426629f3238a3201b8ff92effc Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 16 Mar 2026 14:49:59 +0530 Subject: [PATCH] feat: build Lead Workspace page with KPIs, source grid, lead cards, and sidebar widgets Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/leads/aging-widget.tsx | 71 ++++++++++++ src/components/leads/alerts-widget.tsx | 30 +++++ src/components/leads/followup-widget.tsx | 64 +++++++++++ src/components/leads/kpi-cards.tsx | 93 +++++++++++++++ src/components/leads/lead-card.tsx | 115 +++++++++++++++++++ src/components/leads/source-grid.tsx | 139 +++++++++++++++++++++++ src/pages/lead-workspace.tsx | 86 +++++++++++++- 7 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 src/components/leads/aging-widget.tsx create mode 100644 src/components/leads/alerts-widget.tsx create mode 100644 src/components/leads/followup-widget.tsx create mode 100644 src/components/leads/kpi-cards.tsx create mode 100644 src/components/leads/lead-card.tsx create mode 100644 src/components/leads/source-grid.tsx 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 */} +
);