Files
lynkeduppro-crm/src/components/dashboard/leads.tsx
T
Mayur Shinde fd2e02086e feat(leads+verification): wire Leads & Lead Verification to be-crm data door
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>
2026-07-24 15:20:29 +05:30

985 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>Couldnt 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: "Couldnt 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.18.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: "Couldnt 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>
);
}