From a8ed0c0034ea87df03bace29a466a715d1c8e72c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 29 May 2026 18:18:58 +0530 Subject: [PATCH] feat(leads): enrich lead data + add clickable lead detail page mirroring the create-lead form --- src/App.jsx | 9 + src/data/mockStore.jsx | 163 +++++++++++++--- src/pages/LeadDetailPage.jsx | 349 +++++++++++++++++++++++++++++++++++ src/pages/LeadsListPage.jsx | 8 +- 4 files changed, 504 insertions(+), 25 deletions(-) create mode 100644 src/pages/LeadDetailPage.jsx diff --git a/src/App.jsx b/src/App.jsx index a3f6287..4443fbf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -34,6 +34,7 @@ import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage'; import CreateLeadPage from './pages/CreateLeadPage'; import LeadsListPage from './pages/LeadsListPage'; +import LeadDetailPage from './pages/LeadDetailPage'; import LynkDispatchPage from './pages/LynkDispatchPage'; import EstimatesPage from './pages/EstimatesPage'; import KanbanPage from './pages/KanbanPage'; @@ -125,6 +126,14 @@ function App() { } /> + + + + } + /> { - const canvasserName = canvassers[idx % canvassers.length]; - const createdByName = creators[(idx + 2) % creators.length]; - // Build a realistic phone number from the index + const canvasserName = canvassers[idx % canvassers.length]; + const createdByName = creators[(idx + 2) % creators.length]; + + // Primary phone (area-code varies by parity) const areaCode = idx % 2 === 0 ? '469' : '972'; - const mid = String(500 + idx).padStart(3, '0'); - const last = String(1000 + idx * 37).padStart(4, '0').slice(-4); - const phoneNumber = `(${areaCode}) ${mid}-${last}`; + const mid = String(500 + idx).padStart(3, '0'); + const last = String(1000 + idx * 37).padStart(4, '0').slice(-4); + const primaryPhone = `(${areaCode}) ${mid}-${last}`; + + // Second phone for every third lead + const phones = [{ id: '1', number: primaryPhone, type: 'Mobile', isPrimary: true }]; + if (idx % 3 === 0) { + const homeAC = idx % 2 === 0 ? '214' : '817'; + const homeMid = String(600 + idx).padStart(3, '0'); + const homeLast = String(2000 + idx * 13).padStart(4, '0').slice(-4); + phones.push({ id: '2', number: `(${homeAC}) ${homeMid}-${homeLast}`, type: 'Home', isPrimary: false }); + } + + // Email + const domain = emailDomains[idx % emailDomains.length]; + const emails = [{ + email: `${sal.firstName.toLowerCase()}.${sal.lastName.toLowerCase()}@${domain}`, + isPrimary: true, + }]; + + // Property + const zip = planoZips[idx % planoZips.length]; + const propertyType = propertyTypes[idx % propertyTypes.length]; + + // Job details + const leadType = leadTypes[idx % leadTypes.length]; + const workType = workTypes[idx % workTypes.length]; + const tradeType = 'Roofing'; + const priority = sal.urgency === 'high' ? 'high' : (idx % 3 === 0 ? 'medium' : 'low'); + const notes = canvassingNotes[idx % canvassingNotes.length]; + + // Assignment + const assignedTo = repNames[idx % repNames.length]; + const followUpDate = followUpDates[idx % followUpDates.length]; + + // Insurance block (only for Insurance leads) + let insuranceBlock = {}; + if (leadType === 'Insurance') { + const ins = insuranceCompanies[idx % insuranceCompanies.length]; + const claimNum = `CLM-2026-${String(1000 + idx).padStart(4, '0')}`; + const policyNum = `POL-${String(80000 + idx * 7).padStart(6, '0')}`; + const claimStat = claimStatuses[idx % claimStatuses.length]; + const adjName = adjusterNames[idx % adjusterNames.length]; + const adjAC = adjusterAreaCodes[idx % adjusterAreaCodes.length]; + const adjMid = String(700 + idx).padStart(3, '0'); + const adjLast = String(3000 + idx * 19).padStart(4, '0').slice(-4); + insuranceBlock = { + insuranceCompany: ins, + claimNumber: claimNum, + claimStatus: claimStat, + adjusterName: adjName, + adjusterPhone: `(${adjAC}) ${adjMid}-${adjLast}`, + policyNumber: policyNum, + }; + } else { + insuranceBlock = { + insuranceCompany: '', + claimNumber: '', + claimStatus: '', + adjusterName: '', + adjusterPhone: '', + policyNumber: '', + }; + } return { - id: sal.id, - firstName: sal.firstName, - lastName: sal.lastName, - address: sal.address, - city: sal.city, - state: sal.state, - urgency: sal.urgency, - status: sal.status, - leadSource: sal.leadSource, - stormSource: sal.stormSource, // truthy object — renders Storm Zone badge - createdAt: sal.createdAt, + // ── Original fields (kept as-is for LeadsListPage card) ── + id: sal.id, + firstName: sal.firstName, + lastName: sal.lastName, + address: sal.address, + city: sal.city, + state: sal.state, + urgency: sal.urgency, + status: sal.status, + leadSource: sal.leadSource, + stormSource: sal.stormSource, + createdAt: sal.createdAt, canvasserName, createdByName, - phones: [{ number: phoneNumber, isPrimary: true }], isQuickCapture: false, + // ── Enriched phone list ── + phones, + // ── Contact ── + emails, + // ── Property ── + zip, + propertyType, + propertyPhotos: [], + // ── Job Details ── + leadType, + workType, + tradeType, + referralNote: '', + canvasserId: '', + notes, + // ── Assignment ── + assignedTo, + priority, + followUpDate, + // ── Insurance ── + ...insuranceBlock, }; }); } diff --git a/src/pages/LeadDetailPage.jsx b/src/pages/LeadDetailPage.jsx new file mode 100644 index 0000000..37f8787 --- /dev/null +++ b/src/pages/LeadDetailPage.jsx @@ -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 ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Section card wrapper +// --------------------------------------------------------------------------- +function Section({ icon: Icon, title, accentColor, isDark, children }) { + return ( + +
+
+ +
+

+ {title} +

+
+
+ {children} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Label + value row +// --------------------------------------------------------------------------- +function Field({ label, value }) { + const isEmpty = !value || (typeof value === 'string' && value.trim() === ''); + return ( +
+ + {label} + + + {isEmpty ? '—' : value} + +
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ +

+ Lead Not Found +

+

+ The lead {leadId} does not exist. +

+ +
+ ); + } + + // ── 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 ( +
+
+ + {/* ── Back button ─────────────────────────────────────────── */} + + + {/* ── Hero header card ─────────────────────────────────────── */} + +
+ {/* Avatar */} +
+ {initials || '?'} +
+ + {/* Name + badges */} +
+

+ {lead.firstName} {lead.lastName} +

+
+ {urgencyCfg.label} + {lead.status} + {lead.stormSource && ( + + + Storm Zone + + )} + {lead.isQuickCapture && ( + + + Quick + + )} +
+ {lead.stormSource && ( +

+ {lead.stormSource.areaName} — {lead.stormSource.date} — {lead.stormSource.maxHailSize}" hail ({lead.stormSource.severity}) +

+ )} +
+ + {/* Lead ID pill */} + + {lead.id} + +
+
+ + {/* ── Contact ─────────────────────────────────────────────── */} +
+ {/* Phones */} +
+ + Phone Numbers + + {lead.phones?.length > 0 ? ( +
+ {lead.phones.map((p, i) => ( +
+ + {p.number} + + {p.type ?? 'Mobile'} + + {p.isPrimary && ( + + Primary + + )} +
+ ))} +
+ ) : ( + + )} +
+ + {/* Emails */} +
+ + Email Addresses + + {lead.emails?.length > 0 ? ( +
+ {lead.emails.map((e, i) => ( +
+ + {e.email} + {e.isPrimary && ( + + Primary + + )} +
+ ))} +
+ ) : ( + + )} +
+
+ + {/* ── Property ────────────────────────────────────────────── */} +
+
+
+ +
+ + + + +
+
+ + {/* ── Job Details ─────────────────────────────────────────── */} +
+
+ + + + + + + {lead.referralNote && ( +
+ +
+ )} + {lead.notes && ( +
+ +
+ )} +
+
+ + {/* ── Insurance (only if present) ──────────────────────────── */} + {hasInsurance && ( +
+
+ + + + + + +
+
+ )} + + {/* ── Assignment ──────────────────────────────────────────── */} +
+
+ +
+ + Priority + +
+ {lead.priority ? ( + + {priorityCfg.label} + + ) : ( + + )} +
+
+ + +
+ +
+
+
+ +
+
+ ); +} diff --git a/src/pages/LeadsListPage.jsx b/src/pages/LeadsListPage.jsx index 4bceea3..96aedff 100644 --- a/src/pages/LeadsListPage.jsx +++ b/src/pages/LeadsListPage.jsx @@ -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'