feat(leads): enrich lead data + add clickable lead detail page mirroring the create-lead form
This commit is contained in:
@@ -34,6 +34,7 @@ import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard
|
|||||||
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
||||||
import CreateLeadPage from './pages/CreateLeadPage';
|
import CreateLeadPage from './pages/CreateLeadPage';
|
||||||
import LeadsListPage from './pages/LeadsListPage';
|
import LeadsListPage from './pages/LeadsListPage';
|
||||||
|
import LeadDetailPage from './pages/LeadDetailPage';
|
||||||
import LynkDispatchPage from './pages/LynkDispatchPage';
|
import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||||
import EstimatesPage from './pages/EstimatesPage';
|
import EstimatesPage from './pages/EstimatesPage';
|
||||||
import KanbanPage from './pages/KanbanPage';
|
import KanbanPage from './pages/KanbanPage';
|
||||||
@@ -125,6 +126,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/leads/view/:leadId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT']}>
|
||||||
|
<LeadDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/emp/fa/leads/:leadId"
|
path="/emp/fa/leads/:leadId"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+139
-24
@@ -7662,12 +7662,8 @@ const MOCK_NOTIFICATIONS = [
|
|||||||
// --- SEED HELPERS ---
|
// --- SEED HELPERS ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps MOCK_STORM_ATTRIBUTION_LEADS into the shape LeadsListPage expects.
|
* Maps MOCK_STORM_ATTRIBUTION_LEADS into the shape LeadsListPage expects,
|
||||||
* Field coverage:
|
* enriched with the full field set mirroring the CreateLeadPage form.
|
||||||
* id, firstName, lastName, address, city, state,
|
|
||||||
* urgency, status, leadSource, stormSource (truthy obj),
|
|
||||||
* phones[{ number, isPrimary }],
|
|
||||||
* canvasserName, createdByName, createdAt
|
|
||||||
*/
|
*/
|
||||||
function seedLeadsFromAttribution() {
|
function seedLeadsFromAttribution() {
|
||||||
const canvassers = [
|
const canvassers = [
|
||||||
@@ -7684,32 +7680,151 @@ function seedLeadsFromAttribution() {
|
|||||||
'Shelby Greer',
|
'Shelby Greer',
|
||||||
'Dalton Pruitt',
|
'Dalton Pruitt',
|
||||||
];
|
];
|
||||||
|
const emailDomains = ['gmail.com', 'yahoo.com', 'outlook.com', 'gmail.com', 'yahoo.com'];
|
||||||
|
const planoZips = ['75023', '75024', '75025', '75074', '75075', '75093'];
|
||||||
|
const propertyTypes = ['Single Family', 'Single Family', 'Townhome', 'Single Family', 'Multi-Family', 'Single Family'];
|
||||||
|
const leadTypes = ['Insurance', 'Insurance', 'Retail', 'Insurance', 'Retail', 'Insurance'];
|
||||||
|
const workTypes = ['Roof Replacement', 'Roof Repair', 'Roof Replacement', 'Siding', 'Gutters', 'Windows'];
|
||||||
|
const insuranceCompanies = ['State Farm', 'Allstate', 'Farmers', 'USAA'];
|
||||||
|
const claimStatuses = ['Filed', 'Under Review', 'Approved'];
|
||||||
|
const adjusterNames = [
|
||||||
|
'Marcus Powell', 'Linda Forsythe', 'Craig Delgado', 'Susan Merritt',
|
||||||
|
'Jeff Okonkwo', 'Diane Castillo',
|
||||||
|
];
|
||||||
|
const adjusterAreaCodes = ['972', '469', '214'];
|
||||||
|
const repNames = ['Jesus Gonzales', 'Sarah Calloway'];
|
||||||
|
// Near-future 2026 follow-up dates, deterministic by idx
|
||||||
|
const followUpDates = [
|
||||||
|
'2026-06-04', '2026-06-06', '2026-06-09', '2026-06-11', '2026-06-13',
|
||||||
|
'2026-06-16', '2026-06-18', '2026-06-20', '2026-06-23', '2026-06-25',
|
||||||
|
'2026-06-27', '2026-06-30', '2026-07-02', '2026-07-07', '2026-07-09',
|
||||||
|
'2026-07-11', '2026-07-14', '2026-07-16', '2026-07-18', '2026-07-21',
|
||||||
|
'2026-07-23', '2026-07-25', '2026-07-28', '2026-07-30', '2026-08-01',
|
||||||
|
'2026-08-04', '2026-08-06', '2026-08-08', '2026-08-11', '2026-08-13',
|
||||||
|
'2026-08-15', '2026-08-18', '2026-08-20', '2026-08-22', '2026-08-25',
|
||||||
|
];
|
||||||
|
const canvassingNotes = [
|
||||||
|
'Homeowner showed significant granule loss on south-facing slopes; agreed to inspection.',
|
||||||
|
'Visible dents on ridge cap and gutters from recent hail event; very interested.',
|
||||||
|
'Neighbor referral — two doors down already signed. Motivated to move quickly.',
|
||||||
|
'Homeowner working with insurer; requested second opinion on adjuster estimate.',
|
||||||
|
'Front elevation has missing shingles post-storm. Homeowner confirmed claim filed.',
|
||||||
|
'Spoke with spouse — primary decision-maker out of town until next week.',
|
||||||
|
'Property flagged on storm map; homeowner unaware of damage. Scheduled walkthrough.',
|
||||||
|
'Older roof (est. 15 yr); multiple soft spots noted. Prime re-roof candidate.',
|
||||||
|
'Hail dents visible on AC unit and metal flashing. Homeowner eager to proceed.',
|
||||||
|
'HOA required repair; homeowner wants professional documentation for board approval.',
|
||||||
|
];
|
||||||
|
|
||||||
// Deterministic assignment based on SAL index so output is stable.
|
// Deterministic assignment based on SAL index so output is stable.
|
||||||
return MOCK_STORM_ATTRIBUTION_LEADS.map((sal, idx) => {
|
return MOCK_STORM_ATTRIBUTION_LEADS.map((sal, idx) => {
|
||||||
const canvasserName = canvassers[idx % canvassers.length];
|
const canvasserName = canvassers[idx % canvassers.length];
|
||||||
const createdByName = creators[(idx + 2) % creators.length];
|
const createdByName = creators[(idx + 2) % creators.length];
|
||||||
// Build a realistic phone number from the index
|
|
||||||
|
// Primary phone (area-code varies by parity)
|
||||||
const areaCode = idx % 2 === 0 ? '469' : '972';
|
const areaCode = idx % 2 === 0 ? '469' : '972';
|
||||||
const mid = String(500 + idx).padStart(3, '0');
|
const mid = String(500 + idx).padStart(3, '0');
|
||||||
const last = String(1000 + idx * 37).padStart(4, '0').slice(-4);
|
const last = String(1000 + idx * 37).padStart(4, '0').slice(-4);
|
||||||
const phoneNumber = `(${areaCode}) ${mid}-${last}`;
|
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 {
|
return {
|
||||||
id: sal.id,
|
// ── Original fields (kept as-is for LeadsListPage card) ──
|
||||||
firstName: sal.firstName,
|
id: sal.id,
|
||||||
lastName: sal.lastName,
|
firstName: sal.firstName,
|
||||||
address: sal.address,
|
lastName: sal.lastName,
|
||||||
city: sal.city,
|
address: sal.address,
|
||||||
state: sal.state,
|
city: sal.city,
|
||||||
urgency: sal.urgency,
|
state: sal.state,
|
||||||
status: sal.status,
|
urgency: sal.urgency,
|
||||||
leadSource: sal.leadSource,
|
status: sal.status,
|
||||||
stormSource: sal.stormSource, // truthy object — renders Storm Zone badge
|
leadSource: sal.leadSource,
|
||||||
createdAt: sal.createdAt,
|
stormSource: sal.stormSource,
|
||||||
|
createdAt: sal.createdAt,
|
||||||
canvasserName,
|
canvasserName,
|
||||||
createdByName,
|
createdByName,
|
||||||
phones: [{ number: phoneNumber, isPrimary: true }],
|
|
||||||
isQuickCapture: false,
|
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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
function LeadCard({ lead, index }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
const navigate = useNavigate();
|
||||||
const primaryPhone = lead.phones?.find(p => p.isPrimary) ?? lead.phones?.[0];
|
const primaryPhone = lead.phones?.find(p => p.isPrimary) ?? lead.phones?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +70,12 @@ function LeadCard({ lead, index }) {
|
|||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ type: 'spring', stiffness: 280, damping: 28, delay: index * 0.04 }}
|
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
|
${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-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'
|
: 'bg-white border-zinc-200/60 shadow-sm hover:shadow-md'
|
||||||
|
|||||||
Reference in New Issue
Block a user