feat(leads): enrich lead data + add clickable lead detail page mirroring the create-lead form
This commit is contained in:
@@ -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} — {lead.stormSource.date} — {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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user