forked from Goutam/lynkeduppro-crm
fd2e02086e
Integrates the Leads and Lead Verification screens with the live be-crm backend (crm.lead.* / crm.leadVerification.* / crm.media.*) behind a mode-agnostic data layer that still falls back to the local mock when the Shell isn't configured. Data layer (new) - src/lib/leads-api.ts — crm.lead.search/get/stats/create/update/ updateStatus/assign/sendForVerification + media (presign upload/download) - src/lib/verify-api.ts — crm.leadVerification.search/get/stats/verify/ markUnverified/assign/reassign/moveToPending Backend → FE wiring - Assignee / creator names: read the resolved DTO fields and, as a safety net, resolve member ids → real names against crm.team.member.search (be-crm currently echoes the raw principalId as the name). - Created By: read the createdBy object the backend returns (was blank). - Site photos: upload via crm.media.presignUpload → PUT, persist through crm.lead.attachment.add (per photo, after create), render in the detail popup via crm.media.presignDownload. - Duplicate guard: surface the backend's real error (detail / duplicate_lead) instead of the SDK's opaque "data command failed: 400". UX - Leads detail: Property Photos gallery + edit-mode photo add/remove. - Verification: "Change Assignee" now opens a searchable, scrollable popup instead of a long inline dropdown. - Removed the static "Storm Zone" tag from lead cards/detail; storm banner only renders when the lead carries storm data. Reference / follow-ups - leads.http, leads.postman_collection.json — be-crm data-door requests. - LEADS_BACKEND_CHANGES.md — required be-crm changes (name resolution, assignee carry-over on sendForVerification) verified against :4010. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
985 lines
52 KiB
TypeScript
985 lines
52 KiB
TypeScript
"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<Lead | null>(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<Set<string>>(() => 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 (
|
||
<div className="view leads">
|
||
<PageHead
|
||
eyebrow="Sales"
|
||
title="Leads"
|
||
subtitle={`${total} total leads`}
|
||
icon="leads"
|
||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
|
||
/>
|
||
|
||
{/* ---- stat strip ---------------------------------------- */}
|
||
<div className="leads-stats">
|
||
<StatCard label="Total leads" value={total} icon="leads" tone="orange" />
|
||
<StatCard label="New" value={byStatus.new} icon="star" tone="blue" />
|
||
<StatCard label="Contacted" value={byStatus.contacted} icon="phone" tone="orange" />
|
||
<StatCard label="Appointed" value={byStatus.appointed} icon="clock" tone="purple" />
|
||
<StatCard label="Closed" value={byStatus.closed} icon="check-circle" tone="green" />
|
||
</div>
|
||
|
||
{/* ---- toolbar ------------------------------------------- */}
|
||
<div className="leads-toolbar">
|
||
<div className="leads-search">
|
||
<Icon name="search" size={16} />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search by name, address, source…"
|
||
aria-label="Search leads"
|
||
/>
|
||
{query && <button className="leads-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||
</div>
|
||
<div className="leads-tabs" role="tablist">
|
||
{STATUS_TABS.map((t) => (
|
||
<button
|
||
key={t.value}
|
||
role="tab"
|
||
aria-selected={filter === t.value}
|
||
className={`leads-tab ${filter === t.value ? "active" : ""}`}
|
||
onClick={() => setFilter(t.value)}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ---- board --------------------------------------------- */}
|
||
{error ? (
|
||
<div className="card leads-empty">
|
||
<Icon name="alert" size={30} />
|
||
<h3>Couldn’t load leads</h3>
|
||
<p>{error}</p>
|
||
</div>
|
||
) : loading && filtered.length === 0 ? (
|
||
<div className="card leads-empty">
|
||
<Icon name="refresh" size={30} />
|
||
<h3>Loading leads…</h3>
|
||
<p>Fetching the Plano pipeline.</p>
|
||
</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="card leads-empty">
|
||
<Icon name="search" size={30} />
|
||
<h3>No leads match</h3>
|
||
<p>Try a different search or clear the status filter.</p>
|
||
</div>
|
||
) : (
|
||
<div className="leads-grid">
|
||
{filtered.map((l) => (
|
||
<LeadCard key={l.id} lead={l} onOpen={() => openLead(l)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<LeadDetail
|
||
lead={selected}
|
||
onClose={() => 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)))}
|
||
/>
|
||
<NewLead open={newOpen} onClose={() => setNewOpen(false)} createLead={data.createLead} reps={data.reps} uploadPhoto={data.uploadPhoto} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- */
|
||
/* Stat card */
|
||
/* ---------------------------------------------------------- */
|
||
|
||
function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) {
|
||
return (
|
||
<div className={`leads-stat tone-${tone}`}>
|
||
<span className="leads-stat-ic"><Icon name={icon} size={17} /></span>
|
||
<div className="leads-stat-body">
|
||
<div className="leads-stat-val">{value}</div>
|
||
<div className="leads-stat-lbl">{label}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- */
|
||
/* 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 (
|
||
<button className="lead-card" onClick={onOpen}>
|
||
<div className="lead-card-top">
|
||
<span className={`lead-ava prio-${lead.priority}`}>
|
||
<Avatar initials={lead.initials} gradient={lead.gradient} size={46} />
|
||
</span>
|
||
<div className="lead-card-id">
|
||
<div className="lead-card-name">{lead.name}</div>
|
||
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span></div>
|
||
</div>
|
||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||
</div>
|
||
|
||
<div className="lead-card-row"><Icon name="pin" size={14} /><span>{lead.property.address}, {lead.property.city}, {lead.property.state}</span></div>
|
||
<div className="lead-card-row"><Icon name="phone" size={14} /><span>{primaryPhone?.number}</span></div>
|
||
|
||
<div className="lead-card-foot">
|
||
<Pill tone={status.tone}>{status.label}</Pill>
|
||
<span className="lead-source"><Icon name="pin" size={12} /> {lead.job.source}</span>
|
||
<span className="lead-card-spacer" />
|
||
<span className="lead-rep" title={`Canvasser: ${lead.job.canvasser}`}>{lead.job.canvasser}</span>
|
||
<span className="lead-updated">{lead.updated}</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- */
|
||
/* 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 <input type=date>.
|
||
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 <LeadDetailBody key={lead.refId ?? lead.id} lead={lead} onClose={onClose} data={data} reps={reps} onHydrate={onHydrate} sent={sent} onSent={onSent} />;
|
||
}
|
||
|
||
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<LeadEdit | null>(null);
|
||
const [uploading, setUploading] = useState(0);
|
||
const editFileRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||
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 (
|
||
<Modal
|
||
open
|
||
onClose={onClose}
|
||
size="lg"
|
||
title={lead.name}
|
||
subtitle={lead.id}
|
||
icon="leads"
|
||
footer={editing ? (
|
||
<>
|
||
<Btn variant="ghost" onClick={cancelEdit} disabled={saving}>Cancel</Btn>
|
||
<Btn icon="check" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save Changes"}</Btn>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Btn variant="ghost" icon="edit" onClick={startEdit}>Edit</Btn>
|
||
<Btn variant="ghost" icon="phone" onClick={call}>Call</Btn>
|
||
<Btn variant="outline" icon="mail" onClick={email}>Email</Btn>
|
||
<Btn icon={alreadySent ? "check-circle" : "verify"} onClick={sendForVerification} disabled={sending || alreadySent}>
|
||
{alreadySent ? "Sent for Verification" : sending ? "Sending…" : "Send for Verification"}
|
||
</Btn>
|
||
</>
|
||
)}
|
||
>
|
||
{editing && f ? (
|
||
<div className="nl-form lead-edit">
|
||
<div className="ld-section-head"><Icon name="user" size={15} /> Contact</div>
|
||
<div className="nl-grid">
|
||
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={setField("firstName")} placeholder="John" /></Field>
|
||
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={setField("lastName")} placeholder="Smith" /></Field>
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Phone Numbers</div>
|
||
{f.phones.map((p, i) => (
|
||
<div className="nl-multirow" key={i}>
|
||
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
|
||
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
|
||
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
|
||
</div>
|
||
))}
|
||
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
|
||
</div>
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Email Addresses</div>
|
||
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
|
||
{f.emails.map((em, i) => (
|
||
<div className="nl-multirow" key={i}>
|
||
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
|
||
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ld-section-head"><Icon name="owners" size={15} /> Property</div>
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={setField("address")} placeholder="123 Main St" /></Field></div>
|
||
<Field label="City"><input className="ds-input" value={f.city} onChange={setField("city")} placeholder="Plano" /></Field>
|
||
<Field label="State"><input className="ds-input" value={f.state} onChange={setField("state")} placeholder="TX" /></Field>
|
||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={setField("zip")} placeholder="75023" /></Field>
|
||
<Field label="Property Type"><Select value={f.propertyType} onChange={setField("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Select type…" /></Field>
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Site Photos</div>
|
||
<input ref={editFileRef} type="file" accept="image/*" multiple hidden onChange={onPickEditPhotos} />
|
||
{f.photos.length > 0 && (
|
||
<div className="ld-photos nl-edit-photos">
|
||
{f.photos.map((p) => (
|
||
<div key={p.contentRef} className="ld-photo-edit">
|
||
<LeadPhoto att={p} getUrl={data.getPhotoUrl} />
|
||
<button type="button" className="ld-photo-x" aria-label={`Remove ${p.filename}`} onClick={() => removeEditPhoto(p.contentRef)}><Icon name="x" size={13} /></button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<button type="button" className="nl-add" onClick={() => editFileRef.current?.click()}>
|
||
<Icon name="camera" size={14} /> {uploading > 0 ? `Uploading ${uploading}…` : "Add Photos"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ld-section-head"><Icon name="projects" size={15} /> Job Details</div>
|
||
<div className="nl-grid">
|
||
<Field label="Lead Source"><Select value={f.source} onChange={setField("source")} options={LEAD_SOURCES} placeholder="Source…" /></Field>
|
||
<Field label="Lead Type"><Select value={f.leadType} onChange={setField("leadType")} options={LEAD_TYPE_OPTS} placeholder="Type…" /></Field>
|
||
<Field label="Work Type"><Select value={f.workType} onChange={setField("workType")} options={WORK_TYPES} placeholder="Work…" /></Field>
|
||
<Field label="Trade Type"><Select value={f.tradeType} onChange={setField("tradeType")} options={TRADE_TYPES} placeholder="Trade…" /></Field>
|
||
<Field label="Urgency"><Select value={f.urgency} onChange={setField("urgency")} options={["Standard", "High", "Emergency"]} /></Field>
|
||
<div className="nl-full"><Field label="Field Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={setField("notes")} /></Field></div>
|
||
</div>
|
||
|
||
<div className="ld-section-head"><Icon name="shield" size={15} /> Insurance</div>
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={setField("insCompany")} placeholder="State Farm" /></Field></div>
|
||
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={setField("claimNumber")} /></Field>
|
||
<Field label="Claim Status"><Select value={f.claimStatus} onChange={setField("claimStatus")} options={CLAIM_STATUSES} placeholder="Status…" /></Field>
|
||
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={setField("adjusterName")} /></Field>
|
||
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={setField("adjusterPhone")} /></Field>
|
||
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={setField("policyNumber")} /></Field></div>
|
||
</div>
|
||
|
||
<div className="ld-section-head"><Icon name="team" size={15} /> Assignment</div>
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={setField("assignRep")} options={repOptions} /></Field></div>
|
||
<Field label="Priority"><Select value={f.priority} onChange={setField("priority")} options={["Low", "Medium", "High"]} /></Field>
|
||
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={setField("followUp")} /></Field>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="lead-detail">
|
||
{/* identity strip */}
|
||
<div className="ld-identity">
|
||
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
|
||
<div className="ld-identity-body">
|
||
<div className="ld-identity-name">{lead.name}</div>
|
||
<div className="ld-identity-pills">
|
||
<Pill tone={status.tone}>{status.label}</Pill>
|
||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* storm banner — only when the lead actually carries storm data */}
|
||
{(lead.storm.zone || lead.storm.date || lead.storm.detail) && (
|
||
<div className="ld-storm">
|
||
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
|
||
<div>
|
||
<div className="ld-storm-zone">{lead.storm.zone}</div>
|
||
<div className="ld-storm-meta">{[lead.storm.date, lead.storm.detail].filter(Boolean).join(" · ")}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Property photos */}
|
||
{(lead.attachments?.length ?? 0) > 0 && (
|
||
<div className="ld-photo-block">
|
||
<div className="ld-section-head"><Icon name="camera" size={15} /> Property Photos</div>
|
||
<div className="ld-photos">
|
||
{lead.attachments!.map((a) => (
|
||
<LeadPhoto key={a.contentRef} att={a} getUrl={data.getPhotoUrl} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="ld-grid">
|
||
{/* Contact */}
|
||
<Section title="Contact" icon="user">
|
||
<div className="ld-sublabel">Phone Numbers</div>
|
||
{lead.phones.map((p, i) => (
|
||
<div className="ld-contact-row" key={i}>
|
||
<Icon name="phone" size={14} />
|
||
<span className="ld-contact-val">{p.number}</span>
|
||
<span className="ld-contact-tag">{p.type}</span>
|
||
{p.primary && <Pill tone="green">Primary</Pill>}
|
||
</div>
|
||
))}
|
||
<div className="ld-sublabel">Email Addresses</div>
|
||
{lead.emails.map((e, i) => (
|
||
<div className="ld-contact-row" key={i}>
|
||
<Icon name="mail" size={14} />
|
||
<span className="ld-contact-val">{e.address}</span>
|
||
{e.primary && <Pill tone="green">Primary</Pill>}
|
||
</div>
|
||
))}
|
||
</Section>
|
||
|
||
{/* Property */}
|
||
<Section title="Property" icon="owners">
|
||
<Dl label="Address" value={lead.property.address} />
|
||
<Dl label="City" value={lead.property.city} />
|
||
<Dl label="State" value={lead.property.state} />
|
||
<Dl label="ZIP" value={lead.property.zip} />
|
||
<Dl label="Property Type" value={lead.property.type} />
|
||
</Section>
|
||
|
||
{/* Job Details */}
|
||
<Section title="Job Details" icon="projects">
|
||
<Dl label="Lead Source" value={lead.job.source} />
|
||
<Dl label="Lead Type" value={lead.job.leadType} />
|
||
<Dl label="Work Type" value={lead.job.workType} />
|
||
<Dl label="Trade Type" value={lead.job.tradeType} />
|
||
<Dl label="Urgency" value={lead.job.urgency} />
|
||
<Dl label="Canvasser" value={lead.job.canvasser} />
|
||
<div className="ld-notes">
|
||
<div className="ld-sublabel">Field Notes</div>
|
||
<p>{lead.job.notes}</p>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Insurance */}
|
||
<Section title="Insurance" icon="shield">
|
||
<Dl label="Insurance Company" value={lead.insurance.company} />
|
||
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
|
||
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
|
||
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
|
||
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
|
||
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
||
</Section>
|
||
|
||
{/* Assignment */}
|
||
<Section title="Assignment" icon="team" wide>
|
||
<div className="ld-assign">
|
||
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
|
||
<Dl label="Priority" value={lead.assignment.priority} />
|
||
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
|
||
<Dl label="Created By" value={lead.assignment.createdBy} />
|
||
<Dl label="Created At" value={lead.assignment.createdAt} />
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Section({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
|
||
return (
|
||
<div className={`ld-section ${wide ? "wide" : ""}`}>
|
||
<div className="ld-section-head"><Icon name={icon} size={15} /> {title}</div>
|
||
<div className="ld-section-body">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Dl({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="ld-dl">
|
||
<span className="ld-dl-k">{label}</span>
|
||
<span className="ld-dl-v">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Renders a stored site photo. `contentRef` is an opaque object key, so we mint a short-lived
|
||
// signed URL on mount (mock mode passes the object URL straight through). Clicking opens full-size.
|
||
function LeadPhoto({ att, getUrl }: { att: Attachment; getUrl: (contentRef: string, mime?: string) => Promise<string> }) {
|
||
const [url, setUrl] = useState<string | null>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
getUrl(att.contentRef, att.mimeType)
|
||
.then((u) => { if (alive) setUrl(u); })
|
||
.catch(() => { if (alive) setFailed(true); });
|
||
return () => { alive = false; };
|
||
}, [att.contentRef, att.mimeType, getUrl]);
|
||
|
||
return (
|
||
<a className="ld-photo" href={url ?? undefined} target="_blank" rel="noreferrer" title={att.filename} onClick={(e) => { if (!url) e.preventDefault(); }}>
|
||
{url
|
||
? <img src={url} alt={att.filename} loading="lazy" />
|
||
: <span className="ld-photo-ph"><Icon name={failed ? "alert" : "camera"} size={18} /></span>}
|
||
</a>
|
||
);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- */
|
||
/* New Lead — Quick / Full Form intake */
|
||
/* ---------------------------------------------------------- */
|
||
|
||
type Priority = "Low" | "Medium" | "High";
|
||
type Urgency = "Standard" | "High" | "Emergency";
|
||
type PhoneRow = { number: string; type: string };
|
||
type EmailRow = { address: string };
|
||
|
||
const FULL_STEPS = [
|
||
{ value: "contact", label: "Contact", icon: "user" },
|
||
{ value: "property", label: "Property", icon: "owners" },
|
||
{ value: "job", label: "Job Details", icon: "projects" },
|
||
{ value: "insurance", label: "Insurance", icon: "shield" },
|
||
{ value: "assignment", label: "Assignment", icon: "team" },
|
||
];
|
||
|
||
// Option lists must match the be-crm enums, or crm.lead.create rejects the value (§8.1–8.4).
|
||
const LEAD_TYPE_OPTS = LEAD_TYPES; // Insurance | Retail
|
||
const PROPERTY_TYPE_OPTS = PROPERTY_TYPES; // Single Family | Multi Family | Commercial
|
||
|
||
const BLANK = {
|
||
firstName: "", lastName: "",
|
||
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
|
||
emails: [] as EmailRow[],
|
||
address: "", city: "", state: "TX", zip: "", propertyType: "",
|
||
photos: [] as UploadedAttachment[],
|
||
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
|
||
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
|
||
assignRep: "", priority: "Medium" as Priority, followUp: "",
|
||
};
|
||
|
||
function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boolean; onClose: () => void; createLead: (input: CreateLeadInput) => Promise<void>; reps: Rep[]; uploadPhoto: (file: File) => Promise<UploadedAttachment> }) {
|
||
const toast = useToast();
|
||
const [mode, setMode] = useState<"quick" | "full">("quick");
|
||
const [section, setSection] = useState("contact");
|
||
const [f, setF] = useState({ ...BLANK });
|
||
const [saving, setSaving] = useState(false);
|
||
const [uploading, setUploading] = useState(0);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const set = (k: string) => (e: { target: { value: string } }) =>
|
||
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
|
||
|
||
// multi-value handlers
|
||
const addPhone = () => setF((s) => ({ ...s, phones: [...s.phones, { number: "", type: "Mobile" }] }));
|
||
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => ({ ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) }));
|
||
const removePhone = (i: number) => setF((s) => ({ ...s, phones: s.phones.filter((_, j) => j !== i) }));
|
||
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
|
||
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
|
||
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
|
||
const removePhoto = (i: number) => setF((s) => ({ ...s, photos: s.photos.filter((_, j) => j !== i) }));
|
||
|
||
// Upload each picked file straight to storage (presign → PUT). The returned {contentRef,…}
|
||
// rides along in crm.lead.create's `photos`, so the photo persists with the lead itself.
|
||
async function onPickPhotos(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const files = Array.from(e.target.files ?? []);
|
||
e.target.value = ""; // let the same file be re-picked after a remove
|
||
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 uploadPhoto(file);
|
||
setF((s) => ({ ...s, photos: [...s.photos, up] }));
|
||
} 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
|
||
function close() { reset(); onClose(); }
|
||
|
||
function buildPayload(): CreateLeadInput {
|
||
return {
|
||
firstName: f.firstName.trim(),
|
||
lastName: f.lastName.trim() || undefined,
|
||
phones: f.phones
|
||
.filter((p) => p.number.trim())
|
||
.map((p, i) => ({ number: p.number.trim(), type: (p.type as "Mobile" | "Home" | "Work"), primary: i === 0 })),
|
||
emails: f.emails.filter((e) => e.address.trim()).map((e, i) => ({ address: e.address.trim(), primary: i === 0 })),
|
||
property: { address: f.address, city: f.city, state: f.state, zip: f.zip, type: f.propertyType || undefined },
|
||
photos: f.photos.length ? f.photos : undefined,
|
||
job: {
|
||
source: f.source || undefined,
|
||
referralNote: f.source === "Referral" ? f.referralNote || undefined : undefined,
|
||
canvasserId: f.source === "Door Knock" ? f.canvasser || undefined : undefined,
|
||
leadType: f.leadType || undefined, workType: f.workType || undefined, tradeType: f.tradeType || undefined,
|
||
urgency: f.urgency, notes: f.notes || undefined,
|
||
},
|
||
insurance: {
|
||
company: f.insCompany || undefined, claimNumber: f.claimNumber || undefined, claimStatus: f.claimStatus || undefined,
|
||
adjusterName: f.adjusterName || undefined, adjusterPhone: f.adjusterPhone || undefined, policyNumber: f.policyNumber || undefined,
|
||
},
|
||
assignment: { assigneeId: f.assignRep || null, priority: f.priority, followUp: f.followUp || undefined },
|
||
};
|
||
}
|
||
|
||
async function submit() {
|
||
const name = `${f.firstName} ${f.lastName}`.trim();
|
||
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
||
setSaving(true);
|
||
try {
|
||
await createLead(buildPayload());
|
||
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
|
||
close();
|
||
} catch (e) {
|
||
toast.push({ tone: "error", title: "Couldn’t create lead", desc: e instanceof Error ? e.message : "Please try again." });
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
const repOptions = [{ id: "", initials: "—", name: "Unassigned", email: "" }, ...reps];
|
||
|
||
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
|
||
const isFirstStep = stepIdx <= 0;
|
||
const isLastStep = stepIdx === FULL_STEPS.length - 1;
|
||
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
|
||
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={close}
|
||
size="lg"
|
||
title="New Lead"
|
||
subtitle="Full lead profile with insurance and assignment details."
|
||
icon="plus"
|
||
footer={
|
||
mode === "full" ? (
|
||
<>
|
||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
|
||
{isLastStep
|
||
? <Btn icon="check" onClick={submit} disabled={saving}>{saving ? "Creating…" : "Create Lead"}</Btn>
|
||
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||
<Btn icon="check" onClick={submit} disabled={saving}>{saving ? "Creating…" : "Create Lead"}</Btn>
|
||
</>
|
||
)
|
||
}
|
||
>
|
||
<div className="nl-form">
|
||
<Segmented
|
||
value={mode}
|
||
onChange={(v) => setMode(v as "quick" | "full")}
|
||
options={[{ value: "quick", label: "Quick", icon: "star" }, { value: "full", label: "Full Form", icon: "edit" }]}
|
||
/>
|
||
|
||
{mode === "quick" ? (
|
||
<div className="nl-grid">
|
||
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
|
||
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
|
||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||
{f.source === "Referral" && (
|
||
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
|
||
)}
|
||
{f.source === "Door Knock" && (
|
||
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={reps} /></Field></div>
|
||
)}
|
||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<SegTabs
|
||
value={section}
|
||
onChange={setSection}
|
||
tabs={FULL_STEPS}
|
||
/>
|
||
|
||
{section === "contact" && (
|
||
<div className="nl-grid">
|
||
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Phone Numbers</div>
|
||
{f.phones.map((p, i) => (
|
||
<div className="nl-multirow" key={i}>
|
||
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
|
||
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
|
||
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
|
||
</div>
|
||
))}
|
||
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
|
||
</div>
|
||
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Email Addresses</div>
|
||
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
|
||
{f.emails.map((em, i) => (
|
||
<div className="nl-multirow" key={i}>
|
||
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
|
||
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{section === "property" && (
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} placeholder="TX" /></Field>
|
||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||
<div className="nl-full">
|
||
<div className="ds-field-lbl">Site Photos</div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
hidden
|
||
onChange={onPickPhotos}
|
||
/>
|
||
<button type="button" className="nl-photos" onClick={() => fileRef.current?.click()}>
|
||
<Icon name="camera" size={22} />
|
||
<span className="nl-photos-t">{uploading > 0 ? `Uploading ${uploading}…` : "Tap to add photos"}</span>
|
||
<span className="nl-photos-s">Camera · Gallery · Multiple allowed · max 25 MB</span>
|
||
</button>
|
||
{f.photos.length > 0 && (
|
||
<div className="nl-photo-chips">
|
||
{f.photos.map((p, i) => (
|
||
<span key={p.contentRef} className="nl-photo-chip">
|
||
<Icon name="check" size={12} /> {p.filename}
|
||
<button type="button" className="nl-photo-chip-x" aria-label={`Remove ${p.filename}`} onClick={() => removePhoto(i)}><Icon name="x" size={12} /></button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{section === "job" && (
|
||
<div className="nl-grid">
|
||
<Field label="Lead Source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||
<Field label="Lead Type"><Select value={f.leadType} onChange={set("leadType")} options={LEAD_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||
<Field label="Work Type"><Select value={f.workType} onChange={set("workType")} options={WORK_TYPES} placeholder="Roof Replacement, Repair…" /></Field>
|
||
<Field label="Trade Type"><Select value={f.tradeType} onChange={set("tradeType")} options={TRADE_TYPES} placeholder="Roofing, Gutter, Siding…" /></Field>
|
||
<div className="nl-full"><UrgencyPicker value={f.urgency} onChange={(u) => setF((s) => ({ ...s, urgency: u }))} /></div>
|
||
<div className="nl-full"><Field label="Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={set("notes")} placeholder="First impression, visible damage, special circumstances…" /></Field></div>
|
||
</div>
|
||
)}
|
||
|
||
{section === "insurance" && (
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={set("insCompany")} placeholder="State Farm" /></Field></div>
|
||
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={set("claimNumber")} placeholder="e.g. CLM-2026-00482" /></Field>
|
||
<Field label="Claim Status"><Select value={f.claimStatus} onChange={set("claimStatus")} options={CLAIM_STATUSES} placeholder="Select status…" /></Field>
|
||
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={set("adjusterName")} placeholder="Full name" /></Field>
|
||
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={set("adjusterPhone")} placeholder="(555) 000-0000" /></Field>
|
||
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={set("policyNumber")} placeholder="e.g. POL-7734892-A" /></Field></div>
|
||
</div>
|
||
)}
|
||
|
||
{section === "assignment" && (
|
||
<div className="nl-grid">
|
||
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></Field></div>
|
||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Select({ value, onChange, options, placeholder }: { value: string; onChange: (e: { target: { value: string } }) => void; options: string[]; placeholder?: string }) {
|
||
return (
|
||
<select className={`ds-select ${!value && placeholder ? "is-placeholder" : ""}`} value={value} onChange={onChange}>
|
||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
function RepSelect({ value, onChange, options }: { value: string; onChange: (e: { target: { value: string } }) => void; options: { id: string; initials: string; name: string }[] }) {
|
||
return (
|
||
<select className="ds-select" value={value} onChange={onChange}>
|
||
{options.map((r) => <option key={r.id || "none"} value={r.id}>{r.id ? `${r.name} · ${r.id}` : "— Unassigned"}</option>)}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
|
||
const [query, setQuery] = useState("");
|
||
const [open, setOpen] = useState(false);
|
||
const selected = options.find((o) => o.id === value);
|
||
const matches = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return options;
|
||
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
|
||
}, [query, options]);
|
||
|
||
if (selected) {
|
||
return (
|
||
<div className="nl-canvasser-chip">
|
||
<Avatar initials={selected.initials} size={28} />
|
||
<span className="nl-canvasser-name">{selected.name}</span>
|
||
<span className="nl-canvasser-email">{selected.email}</span>
|
||
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="nl-canvasser">
|
||
<input
|
||
className="ds-input"
|
||
value={query}
|
||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||
onFocus={() => setOpen(true)}
|
||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||
placeholder="Search canvasser by name or email"
|
||
/>
|
||
{open && matches.length > 0 && (
|
||
<div className="nl-canvasser-menu">
|
||
{matches.map((o) => (
|
||
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
|
||
<Avatar initials={o.initials} size={26} />
|
||
<span className="nl-canvasser-name">{o.name}</span>
|
||
<span className="nl-canvasser-email">{o.email}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{open && matches.length === 0 && (
|
||
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
|
||
return (
|
||
<div className="ds-field">
|
||
<span className="ds-field-lbl">Priority</span>
|
||
<div className="nl-prio">
|
||
{(["Low", "Medium", "High"] as Priority[]).map((p) => (
|
||
<button key={p} type="button" className={`nl-prio-btn ${value === p ? `active ${p.toLowerCase()}` : ""}`} onClick={() => onChange(p)}>{p}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UrgencyPicker({ value, onChange }: { value: Urgency; onChange: (u: Urgency) => void }) {
|
||
return (
|
||
<div className="ds-field">
|
||
<span className="ds-field-lbl">Urgency</span>
|
||
<div className="nl-prio">
|
||
{(["Standard", "High", "Emergency"] as Urgency[]).map((u) => (
|
||
<button key={u} type="button" className={`nl-prio-btn urg ${value === u ? `active ${u.toLowerCase()}` : ""}`} onClick={() => onChange(u)}>{u}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|