"use client"; // ============================================================ // Leads — storm-restoration pipeline board. // · Hero head : storm banner + headline stats (by status) // · Toolbar : search + status filter tabs // · Board : lead cards (avatar, priority ring, status, // address, phone, source, rep, updated) // · Detail : click a lead → rich popup with Contact, // Property, Job Details, Insurance, Assignment // Data comes from leads-data.ts (client-side mock). // ============================================================ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui"; import { STATUS_META, PRIORITY_META, LEAD_SOURCES, LEAD_TYPES, PROPERTY_TYPES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES, type Lead, type LeadStatus, type Rep, type Attachment, } from "./leads-data"; import { useLeadsData, type CreateLeadInput, type LeadsData } from "@/lib/leads-api"; import { MAX_ATTACHMENT_BYTES, isImage, type UploadedAttachment } from "@/lib/media-api"; const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [ { value: "all", label: "All" }, { value: "new", label: "New" }, { value: "contacted", label: "Contacted" }, { value: "appointed", label: "Appointed" }, { value: "closed", label: "Closed" }, ]; export function Leads() { const data = useLeadsData(); const { leads, total, byStatus, loading, error } = data; const [query, setQuery] = useState(""); const [filter, setFilter] = useState<"all" | LeadStatus>("all"); const [selected, setSelected] = useState(null); const [newOpen, setNewOpen] = useState(false); // Leads already pushed to the verification queue this session — blocks a second send // (interim guard until the backend exposes a persistent verification status; see notes). const [sentIds, setSentIds] = useState>(() => new Set()); const idOf = (l: Lead) => l.refId ?? l.id; const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return leads.filter((l) => { const matchStatus = filter === "all" || l.status === filter; const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase(); return matchStatus && (!q || hay.includes(q)); }); }, [leads, query, filter]); // Open a card immediately with its list-level data, then hydrate the full detail (live mode // list rows carry card fields only). Ignore hydration errors — the card data still renders. const openLead = (l: Lead) => { setSelected(l); data.getLead(l).then((full) => setSelected((cur) => (cur && cur.id === l.id ? full : cur))).catch(() => {}); }; return (
setNewOpen(true)}>New Lead} /> {/* ---- stat strip ---------------------------------------- */}
{/* ---- toolbar ------------------------------------------- */}
setQuery(e.target.value)} placeholder="Search by name, address, source…" aria-label="Search leads" /> {query && }
{STATUS_TABS.map((t) => ( ))}
{/* ---- board --------------------------------------------- */} {error ? (

Couldn’t load leads

{error}

) : loading && filtered.length === 0 ? (

Loading leads…

Fetching the Plano pipeline.

) : filtered.length === 0 ? (

No leads match

Try a different search or clear the status filter.

) : (
{filtered.map((l) => ( openLead(l)} /> ))}
)} setSelected(null)} data={data} reps={data.reps} onHydrate={setSelected} sent={!!selected && sentIds.has(idOf(selected))} onSent={() => selected && setSentIds((s) => new Set(s).add(idOf(selected)))} /> setNewOpen(false)} createLead={data.createLead} reps={data.reps} uploadPhoto={data.uploadPhoto} />
); } /* ---------------------------------------------------------- */ /* Stat card */ /* ---------------------------------------------------------- */ function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) { return (
{value}
{label}
); } /* ---------------------------------------------------------- */ /* Lead card */ /* ---------------------------------------------------------- */ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) { const status = STATUS_META[lead.status]; const priority = PRIORITY_META[lead.priority]; const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0]; return ( ); } /* ---------------------------------------------------------- */ /* Lead detail popup */ /* ---------------------------------------------------------- */ type LeadEdit = { firstName: string; lastName: string; phones: PhoneRow[]; emails: EmailRow[]; address: string; city: string; state: string; zip: string; propertyType: string; source: string; leadType: string; workType: string; tradeType: string; urgency: string; notes: string; insCompany: string; claimStatus: string; claimNumber: string; policyNumber: string; adjusterName: string; adjusterPhone: string; assignRep: string; priority: string; followUp: string; photos: Attachment[]; }; // Convert a pretty date ("Jun 4, 2026") — or "—"/"" — to a yyyy-mm-dd value for . function toDateInput(s: string): string { if (!s || s === "—") return ""; const t = Date.parse(s); if (Number.isNaN(t)) return ""; const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function initEdit(lead: Lead, reps: Rep[]): LeadEdit { const [firstName, ...rest] = lead.name.split(/\s+/); const rep = reps.find((r) => r.name === lead.assignment.assignedTo); return { firstName: firstName ?? "", lastName: rest.join(" "), phones: lead.phones.length ? lead.phones.map((p) => ({ number: p.number, type: p.type })) : [{ number: "", type: "Mobile" }], emails: lead.emails.map((e) => ({ address: e.address })), address: lead.property.address, city: lead.property.city, state: lead.property.state, zip: lead.property.zip, propertyType: lead.property.type, source: lead.job.source, leadType: lead.job.leadType, workType: lead.job.workType, tradeType: lead.job.tradeType, urgency: lead.job.urgency || "Standard", notes: lead.job.notes, insCompany: lead.insurance.company, claimStatus: lead.insurance.claimStatus, claimNumber: lead.insurance.claimNumber, policyNumber: lead.insurance.policyNumber, adjusterName: lead.insurance.adjusterName, adjusterPhone: lead.insurance.adjusterPhone, assignRep: rep?.id ?? "", priority: lead.assignment.priority || "Medium", followUp: toDateInput(lead.assignment.followUp), photos: lead.attachments ?? [], }; } function buildPatch(f: LeadEdit): import("@/lib/leads-api").LeadPatch { return { firstName: f.firstName.trim(), lastName: f.lastName.trim(), phones: f.phones.filter((p) => p.number.trim()).map((p, i) => ({ number: p.number.trim(), type: p.type, primary: i === 0 })), emails: f.emails.filter((e) => e.address.trim()).map((e, i) => ({ address: e.address.trim(), primary: i === 0 })), propertyAddress: f.address, propertyCity: f.city, propertyState: f.state, propertyZip: f.zip, propertyType: f.propertyType || undefined, source: f.source || undefined, leadType: f.leadType || undefined, workType: f.workType || undefined, tradeType: f.tradeType || undefined, urgency: f.urgency || undefined, jobNotes: f.notes, insuranceCompany: f.insCompany, insuranceClaimStatus: f.claimStatus || undefined, insuranceClaimNumber: f.claimNumber, insurancePolicyNumber: f.policyNumber, insuranceAdjusterName: f.adjusterName, insuranceAdjusterPhone: f.adjusterPhone, assignedToId: f.assignRep || null, followUpDate: f.followUp || undefined, priority: f.priority, }; } function LeadDetail({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead | null; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) { if (!lead) return null; // Keyed by id so edit state resets when a different lead opens. return ; } function LeadDetailBody({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) { const toast = useToast(); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [sending, setSending] = useState(false); const [f, setF] = useState(null); const [uploading, setUploading] = useState(0); const editFileRef = useRef(null); const status = STATUS_META[lead.status]; const priority = PRIORITY_META[lead.priority]; const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0]; const primaryEmail = lead.emails.find((e) => e.primary) ?? lead.emails[0]; const repOptions = [{ id: "", initials: "—", name: "Unassigned", email: "" }, ...reps]; const call = () => { if (!primaryPhone?.number) { toast.push({ tone: "error", title: "No phone", desc: "This lead has no phone number." }); return; } window.location.href = `tel:${primaryPhone.number.replace(/[^\d+]/g, "")}`; }; const email = () => { if (!primaryEmail?.address) { toast.push({ tone: "error", title: "No email", desc: "This lead has no email address." }); return; } window.location.href = `mailto:${primaryEmail.address}`; }; const startEdit = () => { setF(initEdit(lead, reps)); setEditing(true); }; const cancelEdit = () => { setEditing(false); setF(null); }; const setField = (k: keyof LeadEdit) => (e: { target: { value: string } }) => setF((s) => (s ? { ...s, [k]: e.target.value } : s)); const addPhone = () => setF((s) => (s ? { ...s, phones: [...s.phones, { number: "", type: "Mobile" }] } : s)); const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => (s ? { ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) } : s)); const removePhone = (i: number) => setF((s) => (s ? { ...s, phones: s.phones.filter((_, j) => j !== i) } : s)); const addEmail = () => setF((s) => (s ? { ...s, emails: [...s.emails, { address: "" }] } : s)); const setEmail = (i: number, v: string) => setF((s) => (s ? { ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) } : s)); const removeEmail = (i: number) => setF((s) => (s ? { ...s, emails: s.emails.filter((_, j) => j !== i) } : s)); const removeEditPhoto = (contentRef: string) => setF((s) => (s ? { ...s, photos: s.photos.filter((p) => p.contentRef !== contentRef) } : s)); // Upload picked files to storage (presign → PUT), then stage them on the edit form. They're // attached to the lead on Save via crm.lead.attachment.add (see the reconcile in save()). async function onPickEditPhotos(e: React.ChangeEvent) { const files = Array.from(e.target.files ?? []); e.target.value = ""; for (const file of files) { if (!isImage(file.type)) { toast.push({ tone: "error", title: "Not an image", desc: `${file.name} isn't an image file.` }); continue; } if (file.size > MAX_ATTACHMENT_BYTES) { toast.push({ tone: "error", title: "Too large", desc: `${file.name} exceeds the 25 MB limit.` }); continue; } setUploading((n) => n + 1); try { const up = await data.uploadPhoto(file); setF((s) => (s ? { ...s, photos: [...s.photos, up] } : s)); } catch (err) { toast.push({ tone: "error", title: "Upload failed", desc: err instanceof Error ? err.message : `Couldn't upload ${file.name}.` }); } finally { setUploading((n) => n - 1); } } } async function save() { if (!f) return; if (!`${f.firstName} ${f.lastName}`.trim()) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; } setSaving(true); try { await data.updateLead(lead, buildPatch(f)); // Reconcile photos against what the lead had: attach the newly-added, detach the removed. const orig = lead.attachments ?? []; const toAdd = f.photos.filter((p) => !orig.some((o) => o.contentRef === p.contentRef)); const toRemove = orig.filter((o) => !f.photos.some((p) => p.contentRef === o.contentRef)); for (const a of toAdd) await data.addPhoto(lead, a); for (const r of toRemove) await data.removePhoto(lead, r); try { onHydrate(await data.getLead(lead)); } catch { /* keep current view if re-fetch fails */ } toast.push({ tone: "success", title: "Lead updated", desc: `${`${f.firstName} ${f.lastName}`.trim()} saved.` }); setEditing(false); setF(null); } catch (e) { toast.push({ tone: "error", title: "Update failed", desc: e instanceof Error ? e.message : "Please try again." }); } finally { setSaving(false); } } // Already in the verification queue if the backend says so (verificationId) or we sent it this session. const alreadySent = sent || !!lead.verificationId; async function sendForVerification() { if (alreadySent) return; setSending(true); try { await data.sendForVerification(lead); onSent(); toast.push({ tone: "success", title: "Sent for verification", desc: `${lead.name} added to the verification queue.` }); } catch (e) { toast.push({ tone: "error", title: "Couldn’t send", desc: e instanceof Error ? e.message : "Please try again." }); } finally { setSending(false); } } return ( Cancel {saving ? "Saving…" : "Save Changes"} ) : ( <> Edit Call Email {alreadySent ? "Sent for Verification" : sending ? "Sending…" : "Send for Verification"} )} > {editing && f ? (
Contact
Phone Numbers
{f.phones.map((p, i) => (
setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" /> {f.phones.length > 1 && }
))}
Email Addresses
{f.emails.length === 0 &&
No emails added yet.
} {f.emails.map((em, i) => (
setEmail(i, e.target.value)} placeholder="name@email.com" />
))}
Property
{f.photos.length > 0 && (
{f.photos.map((p) => (
))}
)}
Job Details