feat(leads): enrich lead data + add clickable lead detail page mirroring the create-lead form

This commit is contained in:
Satyam Rastogi
2026-05-29 18:18:58 +05:30
parent aeabcea850
commit a8ed0c0034
4 changed files with 504 additions and 25 deletions
+349
View File
@@ -0,0 +1,349 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { useTheme } from '../context/ThemeContext';
import { useMockStore } from '../data/mockStore';
import {
ChevronLeft, User, MapPin, Briefcase, Shield, Users,
Phone, Mail, CloudLightning, Clock, Calendar,
AlertCircle, CheckCircle2, FileText, Zap, Star,
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Badge helpers (mirrors LeadsListPage)
// ---------------------------------------------------------------------------
const URGENCY_CONFIG = {
standard: { label: 'Standard', bg: 'bg-zinc-200 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' },
high: { label: 'High', bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
emergency: { label: 'Emergency', bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' },
};
const STATUS_CONFIG = {
New: { bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' },
Contacted: { bg: 'bg-purple-100 dark:bg-purple-500/20', text: 'text-purple-700 dark:text-purple-400' },
Appointed: { bg: 'bg-emerald-100 dark:bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-400' },
Closed: { bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-500 dark:text-zinc-400' },
};
const PRIORITY_CONFIG = {
high: { label: 'High', bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' },
medium: { label: 'Medium', bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
low: { label: 'Low', bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-500 dark:text-zinc-400' },
};
function Badge({ children, bg, text }) {
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] font-bold uppercase tracking-wider ${bg} ${text}`}>
{children}
</span>
);
}
// ---------------------------------------------------------------------------
// Section card wrapper
// ---------------------------------------------------------------------------
function Section({ icon: Icon, title, accentColor, isDark, children }) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
className={`rounded-2xl border p-5 ${
isDark
? 'bg-zinc-900/70 backdrop-blur-xl border-white/[0.08]'
: 'bg-white border-zinc-200/70 shadow-sm'
}`}
>
<div className="flex items-center gap-2.5 mb-4">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${accentColor}20` }}
>
<Icon size={14} style={{ color: accentColor }} />
</div>
<h3 className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-sm text-zinc-900 dark:text-white">
{title}
</h3>
</div>
<div className="space-y-3">
{children}
</div>
</motion.div>
);
}
// ---------------------------------------------------------------------------
// Label + value row
// ---------------------------------------------------------------------------
function Field({ label, value }) {
const isEmpty = !value || (typeof value === 'string' && value.trim() === '');
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
{label}
</span>
<span className={`text-sm ${isEmpty ? 'text-zinc-300 dark:text-zinc-600 italic' : 'text-zinc-800 dark:text-zinc-200'}`}>
{isEmpty ? '—' : value}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Format ISO → human date
// ---------------------------------------------------------------------------
function formatDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function LeadDetailPage() {
const { leadId } = useParams();
const navigate = useNavigate();
const { theme } = useTheme();
const { leads } = useMockStore();
const isDark = theme === 'dark';
const lead = leads.find(l => l.id === leadId);
// ── Not found ───────────────────────────────────────────────────────────
if (!lead) {
return (
<div className={`min-h-screen flex flex-col items-center justify-center gap-4 transition-colors duration-300 ${isDark ? 'bg-[#09090b]' : 'bg-zinc-100/80'}`}>
<AlertCircle size={40} className="text-zinc-400" />
<p className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-2xl text-zinc-900 dark:text-white">
Lead Not Found
</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
The lead <span className="font-mono text-zinc-700 dark:text-zinc-300">{leadId}</span> does not exist.
</p>
<button
type="button"
onClick={() => navigate('/emp/fa/leads')}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90"
style={{ backgroundColor: '#3B82F6' }}
>
<ChevronLeft size={14} />
Back to Leads
</button>
</div>
);
}
// ── Derived values ───────────────────────────────────────────────────────
const urgencyCfg = URGENCY_CONFIG[lead.urgency] ?? URGENCY_CONFIG.standard;
const statusCfg = STATUS_CONFIG[lead.status] ?? STATUS_CONFIG.New;
const priorityCfg = PRIORITY_CONFIG[lead.priority] ?? PRIORITY_CONFIG.medium;
const initials = `${lead.firstName?.[0] ?? ''}${lead.lastName?.[0] ?? ''}`.toUpperCase();
const hasInsurance = !!(lead.insuranceCompany);
// ── Layout ───────────────────────────────────────────────────────────────
return (
<div className={`min-h-screen transition-colors duration-300 ${isDark ? 'bg-[#09090b]' : 'bg-zinc-100/80'}`}>
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 lg:py-10 space-y-4">
{/* ── Back button ─────────────────────────────────────────── */}
<button
type="button"
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm font-semibold text-zinc-500 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-white transition-colors"
>
<ChevronLeft size={16} />
Back
</button>
{/* ── Hero header card ─────────────────────────────────────── */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 26 }}
className={`rounded-2xl border p-5 ${
isDark
? 'bg-zinc-900/70 backdrop-blur-xl border-white/[0.08] shadow-[0_4px_24px_rgba(0,0,0,0.4)]'
: 'bg-white border-zinc-200/70 shadow-md'
}`}
>
<div className="flex items-start gap-4">
{/* Avatar */}
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center text-lg font-black text-white shrink-0"
style={{ backgroundColor: '#3B82F6' }}
>
{initials || '?'}
</div>
{/* Name + badges */}
<div className="flex-1 min-w-0">
<h1 className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-2xl leading-tight text-zinc-900 dark:text-white truncate">
{lead.firstName} {lead.lastName}
</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
<Badge bg={urgencyCfg.bg} text={urgencyCfg.text}>{urgencyCfg.label}</Badge>
<Badge bg={statusCfg.bg} text={statusCfg.text}>{lead.status}</Badge>
{lead.stormSource && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 px-2 py-0.5 rounded-full">
<CloudLightning size={10} />
Storm Zone
</span>
)}
{lead.isQuickCapture && (
<span className="inline-flex items-center gap-1 text-[11px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
<Zap size={10} />
Quick
</span>
)}
</div>
{lead.stormSource && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400/80">
{lead.stormSource.areaName} &mdash; {lead.stormSource.date} &mdash; {lead.stormSource.maxHailSize}" hail ({lead.stormSource.severity})
</p>
)}
</div>
{/* Lead ID pill */}
<span className="shrink-0 font-mono text-[10px] text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-lg">
{lead.id}
</span>
</div>
</motion.div>
{/* ── Contact ─────────────────────────────────────────────── */}
<Section icon={User} title="Contact" accentColor="#3B82F6" isDark={isDark}>
{/* Phones */}
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Phone Numbers
</span>
{lead.phones?.length > 0 ? (
<div className="flex flex-col gap-1.5">
{lead.phones.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<Phone size={12} className="text-zinc-400 dark:text-zinc-500 shrink-0" />
<span className="text-sm text-zinc-800 dark:text-zinc-200">{p.number}</span>
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-md">
{p.type ?? 'Mobile'}
</span>
{p.isPrimary && (
<span className="text-[10px] text-blue-500 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded-md font-bold uppercase">
Primary
</span>
)}
</div>
))}
</div>
) : (
<span className="text-sm text-zinc-300 dark:text-zinc-600 italic">—</span>
)}
</div>
{/* Emails */}
<div className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Email Addresses
</span>
{lead.emails?.length > 0 ? (
<div className="flex flex-col gap-1.5">
{lead.emails.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<Mail size={12} className="text-zinc-400 dark:text-zinc-500 shrink-0" />
<span className="text-sm text-zinc-800 dark:text-zinc-200">{e.email}</span>
{e.isPrimary && (
<span className="text-[10px] text-blue-500 bg-blue-50 dark:bg-blue-500/10 px-1.5 py-0.5 rounded-md font-bold uppercase">
Primary
</span>
)}
</div>
))}
</div>
) : (
<span className="text-sm text-zinc-300 dark:text-zinc-600 italic">—</span>
)}
</div>
</Section>
{/* ── Property ────────────────────────────────────────────── */}
<Section icon={MapPin} title="Property" accentColor="#10B981" isDark={isDark}>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Field label="Address" value={lead.address} />
</div>
<Field label="City" value={lead.city} />
<Field label="State" value={lead.state} />
<Field label="ZIP" value={lead.zip} />
<Field label="Property Type" value={lead.propertyType} />
</div>
</Section>
{/* ── Job Details ─────────────────────────────────────────── */}
<Section icon={Briefcase} title="Job Details" accentColor="#F59E0B" isDark={isDark}>
<div className="grid grid-cols-2 gap-3">
<Field label="Lead Source" value={lead.leadSource} />
<Field label="Lead Type" value={lead.leadType} />
<Field label="Work Type" value={lead.workType} />
<Field label="Trade Type" value={lead.tradeType} />
<Field label="Urgency" value={lead.urgency ? lead.urgency.charAt(0).toUpperCase() + lead.urgency.slice(1) : ''} />
<Field label="Canvasser" value={lead.canvasserName} />
{lead.referralNote && (
<div className="col-span-2">
<Field label="Referral Note" value={lead.referralNote} />
</div>
)}
{lead.notes && (
<div className="col-span-2">
<Field label="Field Notes" value={lead.notes} />
</div>
)}
</div>
</Section>
{/* ── Insurance (only if present) ──────────────────────────── */}
{hasInsurance && (
<Section icon={Shield} title="Insurance" accentColor="#8B5CF6" isDark={isDark}>
<div className="grid grid-cols-2 gap-3">
<Field label="Insurance Company" value={lead.insuranceCompany} />
<Field label="Claim Status" value={lead.claimStatus} />
<Field label="Claim Number" value={lead.claimNumber} />
<Field label="Policy Number" value={lead.policyNumber} />
<Field label="Adjuster Name" value={lead.adjusterName} />
<Field label="Adjuster Phone" value={lead.adjusterPhone} />
</div>
</Section>
)}
{/* ── Assignment ──────────────────────────────────────────── */}
<Section icon={Users} title="Assignment" accentColor="#06B6D4" isDark={isDark}>
<div className="grid grid-cols-2 gap-3">
<Field label="Assigned To" value={lead.assignedTo} />
<div>
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Priority
</span>
<div className="mt-1">
{lead.priority ? (
<Badge bg={priorityCfg.bg} text={priorityCfg.text}>
{priorityCfg.label}
</Badge>
) : (
<span className="text-sm text-zinc-300 dark:text-zinc-600 italic">—</span>
)}
</div>
</div>
<Field label="Follow-Up Date" value={formatDate(lead.followUpDate)} />
<Field label="Created By" value={lead.createdByName} />
<div className="col-span-2">
<Field label="Created At" value={formatDate(lead.createdAt)} />
</div>
</div>
</Section>
</div>
</div>
);
}
+7 -1
View File
@@ -62,6 +62,7 @@ function timeAgo(isoString) {
function LeadCard({ lead, index }) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const navigate = useNavigate();
const primaryPhone = lead.phones?.find(p => p.isPrimary) ?? lead.phones?.[0];
return (
@@ -69,7 +70,12 @@ function LeadCard({ lead, index }) {
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 280, damping: 28, delay: index * 0.04 }}
className={`rounded-2xl border p-4 flex items-start gap-4 cursor-pointer transition-shadow duration-200 group
onClick={() => navigate(`/leads/view/${lead.id}`)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/leads/view/${lead.id}`); }}
tabIndex={0}
role="button"
aria-label={`View lead: ${lead.firstName} ${lead.lastName}`}
className={`rounded-2xl border p-4 flex items-start gap-4 cursor-pointer transition-shadow duration-200 group focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
${isDark
? 'bg-zinc-900/70 backdrop-blur-xl border-white/[0.08] hover:border-white/20 shadow-[0_4px_16px_rgba(0,0,0,0.3)]'
: 'bg-white border-zinc-200/60 shadow-sm hover:shadow-md'