feat: build Lead Workspace page with KPIs, source grid, lead cards, and sidebar widgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 14:49:59 +05:30
parent d36f9f39b5
commit 1bed4b7d08
7 changed files with 595 additions and 3 deletions

View File

@@ -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 (
<div>
<h3 className="mb-3 text-sm font-bold text-primary">Lead Aging</h3>
<div className="space-y-3">
{brackets.map((bracket) => (
<div key={bracket.label}>
<div className="mb-1 flex items-center justify-between">
<span className={cx('text-xs', bracket.color)}>{bracket.label}</span>
<span className={cx('text-sm font-bold', bracket.color)}>
{bracket.count}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
<div
className={cx('h-full rounded-full transition-all', bracket.barColor)}
style={{ width: `${(bracket.count / total) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -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 (
<div className="rounded-xl border border-error-subtle bg-error-primary p-4">
<p className="text-xs font-bold text-error-primary">
{agingCount} leads aging &gt; 5 days
</p>
<p className="mt-1 text-xs text-error-primary opacity-80">
These leads haven&apos;t been contacted and are at risk of going cold.
</p>
</div>
);
};

View File

@@ -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 (
<div>
<h3 className="mb-3 text-sm font-bold text-primary">Upcoming Follow-ups</h3>
<div className="space-y-2">
{items.map((item) => {
const isOverdue = overdue.some((o) => o.id === item.id);
return (
<div
key={item.id}
className={cx(
'rounded-lg border-l-2 p-3',
isOverdue
? 'border-l-error-solid bg-error-primary'
: 'border-l-brand-solid bg-secondary',
)}
>
<p
className={cx(
'text-xs font-semibold',
isOverdue ? 'text-error-primary' : 'text-brand-secondary',
)}
>
{item.scheduledAt
? formatShortDate(item.scheduledAt)
: 'No date'}
{isOverdue && ' — Overdue'}
</p>
<p className="mt-0.5 text-xs font-medium text-primary">
{item.description ?? item.followUpType ?? 'Follow-up'}
</p>
{(item.patientName || item.patientPhone) && (
<p className="mt-0.5 text-xs text-tertiary">
{item.patientName}
{item.patientPhone && ` · ${item.patientPhone}`}
</p>
)}
</div>
);
})}
</div>
<Button className="mt-3 w-full" size="sm" color="secondary">
Open Full Schedule
</Button>
</div>
);
};

View File

@@ -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 (
<div className="flex gap-3">
{cards.map((card) => (
<div
key={card.label}
className={cx(
'rounded-2xl p-5 transition hover:shadow-md',
card.isHero
? 'flex-[1.3] bg-brand-solid text-white'
: 'flex-1 border border-secondary bg-primary',
)}
>
<p
className={cx(
'text-xs font-medium uppercase tracking-wider',
card.isHero ? 'text-white/70' : 'text-quaternary',
)}
>
{card.label}
</p>
<p
className={cx(
'mt-1 text-display-sm font-bold',
card.isHero ? 'text-white' : 'text-primary',
)}
>
{card.value}
</p>
<p
className={cx(
'mt-1 text-xs',
card.isHero ? 'text-white/80' : card.deltaColor,
)}
>
{card.delta}
</p>
</div>
))}
</div>
);
};

View File

@@ -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<string, string> = {
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 (
<div
className={cx(
'flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md',
isSpam ? 'bg-warning-primary' : 'bg-primary',
)}
>
{/* Avatar */}
<Avatar size="md" initials={initials} />
{/* Middle content */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-primary">{name}</span>
{lead.leadStatus && <LeadStatusBadge status={lead.leadStatus} />}
{isSpam && (
<Badge size="sm" type="pill-color" color="error">
{lead.spamScore}% Spam Risk
</Badge>
)}
{isDuplicate && (
<Badge size="sm" type="pill-color" color="warning">
Duplicate
</Badge>
)}
</div>
<p className="mt-0.5 truncate text-xs text-tertiary">
{phone}
{lead.interestedService && ` · ${lead.interestedService}`}
{sourceLabel && ` · via ${sourceLabel}`}
{lead.utmCampaign && ` · ${lead.utmCampaign}`}
</p>
</div>
{/* Age + Source */}
<div className="flex items-center gap-3">
{lead.createdAt && <AgeIndicator dateStr={lead.createdAt} />}
{lead.leadSource && <SourceTag source={lead.leadSource} />}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{isSpam ? (
<>
<Button size="sm" color="secondary-destructive" onClick={() => onMarkSpam(lead)}>
Mark Spam
</Button>
<Button size="sm" color="secondary" onClick={() => onMessage(lead)}>
Review
</Button>
</>
) : isDuplicate ? (
<>
<Button size="sm" color="primary" onClick={() => onAssign(lead)}>
Assign
</Button>
<Button size="sm" color="secondary" onClick={() => onMerge(lead)}>
Merge
</Button>
</>
) : (
<>
<Button size="sm" color="primary" onClick={() => onAssign(lead)}>
Assign
</Button>
<Button size="sm" color="secondary" onClick={() => onMessage(lead)}>
Message
</Button>
</>
)}
</div>
</div>
);
};

View File

@@ -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 (
<div className="grid grid-cols-3 gap-3">
{sourceConfigs.map((config) => {
const count = countBySource(config.source);
const isActive = activeSource === config.source;
return (
<button
key={config.source}
type="button"
onClick={() => handleClick(config.source)}
className={cx(
'cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md',
isActive && 'ring-2 ring-brand',
)}
>
<div className="mb-2 flex items-center gap-2">
<span
className={cx(
'flex size-6 items-center justify-center rounded text-xs font-bold',
config.iconBg,
config.iconText,
)}
>
{config.icon}
</span>
<span className="text-xs text-quaternary">{config.label}</span>
</div>
<div className="flex items-baseline gap-2">
<span className={cx('text-xl font-bold', config.countColor)}>
{count}
</span>
<span
className={cx(
'text-xs',
config.delta.startsWith('+')
? 'text-success-primary'
: config.delta === '0'
? 'text-quaternary'
: 'text-error-primary',
)}
>
{config.delta === '0' ? 'same' : config.delta}
</span>
</div>
</button>
);
})}
</div>
);
};

View File

@@ -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 = () => { export const LeadWorkspacePage = () => {
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(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 ( return (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<TopBar title="Lead Workspace" subtitle="Ramaiah Memorial Hospital · Last 24 hours" /> <TopBar title="Lead Workspace" subtitle="Ramaiah Memorial Hospital · Last 24 hours" />
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 overflow-hidden">
<p className="text-tertiary">Lead Workspace coming soon</p> {/* Main content */}
<div className="flex-1 space-y-6 overflow-y-auto p-7">
<KpiCards leads={allLeads} />
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display text-md font-bold text-primary">Lead Sources</h2>
<span className="text-xs text-quaternary">Click to filter</span>
</div>
<SourceGrid leads={allLeads} onSourceFilter={setSourceFilter} activeSource={sourceFilter} />
</div>
<div>
<div className="mb-3.5 flex items-center justify-between">
<h2 className="font-display text-md font-bold text-primary">New Leads</h2>
<Button href="/leads" color="link-color" size="sm">
View All {total} Leads
</Button>
</div>
<div className="space-y-2">
{displayLeads.map((lead) => (
<LeadCard
key={lead.id}
lead={lead}
onAssign={handleAssign}
onMessage={handleMessage}
onMarkSpam={handleMarkSpam}
onMerge={handleMerge}
/>
))}
{displayLeads.length === 0 && (
<p className="py-8 text-center text-sm text-tertiary">
No leads match the current filters.
</p>
)}
</div>
</div>
</div>
{/* Right sidebar */}
<aside className="hidden w-80 space-y-5 overflow-y-auto border-l border-secondary bg-primary p-5 xl:block">
<AgingWidget leads={allLeads} />
<FollowupWidget overdue={overdue} upcoming={upcoming} />
<AlertsWidget leads={allLeads} />
</aside>
</div> </div>
</div> </div>
); );