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>
This commit is contained in:
Mayur Shinde
2026-07-24 15:20:29 +05:30
parent f380c7d465
commit fd2e02086e
10 changed files with 2539 additions and 171 deletions
+11 -1
View File
@@ -12,9 +12,16 @@ export type LeadPriority = "high" | "medium" | "low";
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
export type Email = { address: string; type?: string; primary?: boolean };
/** A site photo (or other file) stored via the media presign flow (§11.5).
* `contentRef` is the IIOS object key — render it through a signed download URL. */
export type Attachment = { id?: string; contentRef: string; mimeType: string; sizeBytes: number; filename: string };
export type Lead = {
id: string; // SAL-001
id: string; // SAL-001 (display code)
refId?: string; // opaque server id (live mode); commands target this — falls back to `id`
/** Set once the lead has been pushed to the verification queue — blocks a duplicate send.
* Populated from the backend (e.g. LeadDTO.verificationId) when available. */
verificationId?: string;
initials: string;
name: string;
gradient: string;
@@ -31,6 +38,9 @@ export type Lead = {
phones: Phone[];
emails: Email[];
// site photos (uploaded via the media presign flow; shown in the detail popup)
attachments?: Attachment[];
// property
property: { address: string; city: string; state: string; zip: string; type: string };
+497 -128
View File
@@ -11,13 +11,15 @@
// Data comes from leads-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
import {
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
type Lead, type LeadStatus,
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" },
@@ -28,44 +30,50 @@ const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
];
export function Leads() {
const toast = useToast();
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);
const countByStatus = useMemo(() => {
const m: Record<string, number> = {};
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
return m;
}, []);
// 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) => {
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));
});
}, [query, filter]);
}, [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_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
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_LEADS} icon="leads" tone="orange" />
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
<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 ------------------------------------------- */}
@@ -96,7 +104,19 @@ export function Leads() {
</div>
{/* ---- board --------------------------------------------- */}
{filtered.length === 0 ? (
{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>
@@ -105,13 +125,21 @@ export function Leads() {
) : (
<div className="leads-grid">
{filtered.map((l) => (
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
<LeadCard key={l.id} lead={l} onOpen={() => openLead(l)} />
))}
</div>
)}
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
<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>
);
}
@@ -149,7 +177,7 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
</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> · <Icon name="storm" size={12} /> {lead.tag}</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>
@@ -172,116 +200,365 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
/* Lead detail popup */
/* ---------------------------------------------------------- */
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
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={!!lead}
open
onClose={onClose}
size="lg"
title={lead.name}
subtitle={`${lead.id} · ${lead.tag}`}
subtitle={lead.id}
icon="leads"
footer={
footer={editing ? (
<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn variant="outline" icon="mail">Email</Btn>
<Btn icon="check-circle">Update Status</Btn>
<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>
</>
)}
>
<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>
{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>
{/* storm banner */}
<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}</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="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 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 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>
</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>
))}
</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} />
{/* 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>
</Section>
)}
<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>
</div>
)}
</Modal>
);
}
@@ -304,6 +581,28 @@ function Dl({ label, value }: { label: string; value: string }) {
);
}
// 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 */
/* ---------------------------------------------------------- */
@@ -321,25 +620,29 @@ const FULL_STEPS = [
{ value: "assignment", label: "Assignment", icon: "team" },
];
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
// 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 string[],
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 }: { open: boolean; onClose: () => void }) {
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);
@@ -351,19 +654,72 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
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 addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
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 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; }
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
close();
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 },
};
}
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
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;
@@ -385,13 +741,13 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
<Btn variant="ghost" onClick={close}>Cancel</Btn>
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
{isLastStep
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
? <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}>Create Lead</Btn>
<Btn icon="check" onClick={submit} disabled={saving}>{saving ? "Creating…" : "Create Lead"}</Btn>
</>
)
}
@@ -417,7 +773,7 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
<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"><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>
@@ -472,14 +828,27 @@ function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
<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>
<button type="button" className="nl-photos" onClick={addPhoto}>
<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">Tap to add photos</span>
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
<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={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
{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>
+3 -2
View File
@@ -10,13 +10,14 @@ export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unv
export type VActivity = { text: string; time: string; who: string };
export type VLead = {
id: string;
id: string; // LD-V-001 (display code)
refId?: string; // opaque server id (live mode); commands target this — falls back to `id`
initials: string;
name: string;
address: string;
phone: string;
source: string;
assignee: { initials: string; name: string } | null;
assignee: { id?: string; initials: string; name: string } | null;
status: VStatus;
verification: string; // sub-status text
created: string;
+161 -40
View File
@@ -16,7 +16,8 @@
import { useMemo, useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
import { V_STATUS_META, V_SOURCES, type VStatus, type VLead, type VActivity } from "./verify-data";
import { useVerifyData, type Assignee } from "@/lib/verify-api";
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
const STAT_ICON: Record<VStatus, string> = {
@@ -42,37 +43,71 @@ function buildActivity(l: VLead): VActivity[] {
type MenuState = { lead: VLead; x: number; y: number } | null;
// Which transitions the backend accepts from a given status. `verified` is re-openable now, so the
// only guard is "you can't move a record to the status it's already in" — every other transition is
// allowed and the backend has the final say (a rejected one surfaces a friendly 409 + auto-refresh).
function allowed(status: VStatus) {
return {
verify: status !== "verified",
unverify: status !== "unverified",
moveToPending: status !== "pending",
};
}
export function Verify() {
const toast = useToast();
const data = useVerifyData();
const { leads, total, counts, assignees, loading, error } = data;
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [source, setSource] = useState("all");
const [assignee, setAssignee] = useState("all");
const [selected, setSelected] = useState<VLead | null>(null);
const [menu, setMenu] = useState<MenuState>(null);
const counts = useMemo(() => {
const m: Record<string, number> = {};
for (const l of V_LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
return m;
}, []);
const [assignFor, setAssignFor] = useState<VLead | null>(null);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return V_LEADS.filter((l) => {
return leads.filter((l) => {
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
const matchStatus = status === "all" || l.status === status;
const matchSource = source === "all" || l.source === source;
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
return matchQ && matchStatus && matchSource && matchAssignee;
});
}, [query, status, source, assignee]);
}, [leads, query, status, source, assignee]);
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
// Run a command, surface success/failure as a toast, and close any open menu.
async function run(work: Promise<void>, title: string, desc: string) {
setMenu(null);
toast.push({ tone, title, desc });
try {
await work;
toast.push({ tone: "success", title, desc });
} catch (e) {
const msg = e instanceof Error ? e.message : "";
// 409 = the record's current state doesn't allow this transition (e.g. it's already
// verified/unverified but the list read was stale). Refetch so the row shows its real status.
const conflict = /\b409\b/.test(msg);
if (conflict) data.refetch();
const friendly = conflict ? "This lead's status already changed — refreshing the queue." : (msg || "Please try again.");
toast.push({ tone: "error", title: "Action failed", desc: friendly });
}
}
const doVerify = (l: VLead) => run(data.verify(l), "Verified", `${l.name} verified and pushed to New Leads.`);
// markUnverified (#17) carries a reason — send a default so the command satisfies a required field.
const doUnverify = (l: VLead) => run(data.markUnverified(l, "Marked unverified from the verification desk."), "Marked unverified", `${l.name} moved to Unverified.`);
const doPending = (l: VLead) => run(data.moveToPending(l), "Moved to pending", `${l.name} is now Pending review.`);
// assign (#11) requires an assigneeId — pick one, then run the command.
const doAssign = (l: VLead, a: Assignee) => run(data.assign(l, a.id), "Assignee changed", `${l.name} assigned to ${a.name}.`);
// Open a row with its list data, then hydrate detail (notes/activity) — ignore hydration errors.
const openDetail = (l: VLead) => {
setSelected(l);
setMenu(null);
data.getVerification(l).then((full) => setSelected((cur) => (cur && cur.id === l.id ? full : cur))).catch(() => {});
};
return (
<div className="view lv">
<PageHead
@@ -80,7 +115,7 @@ export function Verify() {
title="Lead Verification"
subtitle="Identity & insurance checks before a lead becomes a working deal."
icon="verify"
actions={<Btn variant="outline" icon="refresh" onClick={() => toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." })}>Refresh</Btn>}
actions={<Btn variant="outline" icon="refresh" onClick={() => { data.refetch(); toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." }); }}>Refresh</Btn>}
/>
{/* ---- stat tiles ---- */}
@@ -114,7 +149,7 @@ export function Verify() {
</select>
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
<option value="all">All assignees</option>
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
{assignees.map((a) => <option key={a.id} value={a.name}>{a.name}</option>)}
</select>
</div>
@@ -129,7 +164,11 @@ export function Verify() {
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
{error ? (
<tr><td colSpan={9} className="lv-empty">Couldnt load the queue: {error}</td></tr>
) : loading && rows.length === 0 ? (
<tr><td colSpan={9} className="lv-empty">Loading verification queue</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
) : rows.map((l) => {
const meta = V_STATUS_META[l.status];
@@ -160,8 +199,8 @@ export function Verify() {
<td className="lv-created">{l.created}</td>
<td>
<div className="lv-rowacts">
<button className="lv-act" aria-label="View details" title="View details" onClick={() => setSelected(l)}><Icon name="eye" size={15} /></button>
<button className="lv-act primary" aria-label="Verify" title="Verify lead" onClick={() => act(l, "Marked verified", `${l.name} moved to Verified.`, "success")}><Icon name="check-circle" size={15} /></button>
<button className="lv-act" aria-label="View details" title="View details" onClick={() => openDetail(l)}><Icon name="eye" size={15} /></button>
<button className="lv-act primary" aria-label="Verify" title={allowed(l.status).verify ? "Verify lead" : "Already resolved"} onClick={() => doVerify(l)} disabled={!allowed(l.status).verify}><Icon name="check-circle" size={15} /></button>
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
</div>
</td>
@@ -171,10 +210,24 @@ export function Verify() {
</tbody>
</table>
</div>
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
<div className="lv-count">{rows.length} of {total} leads</div>
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
<ActionsMenu
menu={menu}
onClose={() => setMenu(null)}
onView={openDetail}
onVerify={doVerify}
onUnverify={doUnverify}
onPending={doPending}
onOpenAssign={(l) => { setMenu(null); setAssignFor(l); }}
/>
<AssignModal
lead={assignFor}
assignees={assignees}
onClose={() => setAssignFor(null)}
onPick={(l, a) => { setAssignFor(null); doAssign(l, a); }}
/>
<VerifyDetail lead={selected} onClose={() => setSelected(null)} onVerify={doVerify} />
</div>
);
}
@@ -183,10 +236,13 @@ export function Verify() {
/* Row actions dropdown (portalled, fixed-positioned) */
/* ---------------------------------------------------------- */
function ActionsMenu({ menu, onClose, onAct, onView }: {
function ActionsMenu({ menu, onClose, onView, onVerify, onUnverify, onPending, onOpenAssign }: {
menu: MenuState; onClose: () => void;
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => void;
onView: (l: VLead) => void;
onVerify: (l: VLead) => void;
onUnverify: (l: VLead) => void;
onPending: (l: VLead) => void;
onOpenAssign: (l: VLead) => void;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
@@ -202,44 +258,109 @@ function ActionsMenu({ menu, onClose, onAct, onView }: {
const left = Math.max(8, menu.x - 196);
const top = Math.min(menu.y + 8, window.innerHeight - 260);
const items = [
{ icon: "eye", label: "View Details", run: () => onView(l) },
{ icon: "check-circle", label: "Verify Lead", run: () => onAct(l, "Verified", `${l.name} marked as verified.`, "success") },
{ icon: "alert", label: "Mark Unverified", run: () => onAct(l, "Marked unverified", `${l.name} moved to Unverified.`) },
{ icon: "user", label: "Change Assignee", run: () => onAct(l, "Change assignee", `Pick a new rep for ${l.name}.`) },
{ icon: "refresh", label: "Reassign (In Progress)", run: () => onAct(l, "Reassigned", `${l.name} set to In Progress.`) },
{ icon: "clock", label: "Move to Pending", run: () => onAct(l, "Moved to pending", `${l.name} is now Pending review.`) },
];
return createPortal(
<>
<div className="lv-menu-scrim" onClick={onClose} />
<div className="lv-menu" style={{ left, top }} role="menu">
{items.map((it) => (
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run}>
<Icon name={it.icon} size={14} /> {it.label}
</button>
))}
</div>
<MenuBody
key={l.id} lead={l} left={left} top={top}
onView={onView} onVerify={onVerify} onUnverify={onUnverify}
onPending={onPending} onOpenAssign={onOpenAssign}
/>
</>,
document.body,
);
}
function MenuBody({ lead: l, left, top, onView, onVerify, onUnverify, onPending, onOpenAssign }: {
lead: VLead; left: number; top: number;
onView: (l: VLead) => void; onVerify: (l: VLead) => void; onUnverify: (l: VLead) => void;
onPending: (l: VLead) => void; onOpenAssign: (l: VLead) => void;
}) {
const can = allowed(l.status);
// Immediate-fire actions are gated by status (prevents a pointless 409). "Change Assignee" opens
// the assign popup (the command fires after you pick), so it always opens.
const items = [
{ icon: "eye", label: "View Details", enabled: true, run: () => onView(l) },
{ icon: "check-circle", label: "Verify Lead", enabled: can.verify, run: () => onVerify(l) },
{ icon: "alert", label: "Mark Unverified", enabled: can.unverify, run: () => onUnverify(l) },
{ icon: "user", label: "Change Assignee", enabled: true, run: () => onOpenAssign(l) },
{ icon: "clock", label: "Move to Pending", enabled: can.moveToPending, run: () => onPending(l) },
];
return (
<div className="lv-menu" style={{ left, top }} role="menu">
{items.map((it) => (
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run} disabled={!it.enabled}>
<Icon name={it.icon} size={14} /> {it.label}
</button>
))}
</div>
);
}
/* ---------------------------------------------------------- */
/* Change-assignee popup (searchable, scrollable) */
/* ---------------------------------------------------------- */
function AssignModal({ lead, assignees, onClose, onPick }: {
lead: VLead | null; assignees: Assignee[]; onClose: () => void; onPick: (l: VLead, a: Assignee) => void;
}) {
const [q, setQ] = useState("");
if (!lead) return null;
const query = q.trim().toLowerCase();
const matches = query ? assignees.filter((a) => a.name.toLowerCase().includes(query)) : assignees;
return (
<Modal open={!!lead} onClose={onClose} size="sm" title="Change Assignee" subtitle={lead.name} icon="user">
<div className="lv-assign">
<div className="lv-assign-search">
<Icon name="search" size={15} />
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search team members…" aria-label="Search team members" />
{q && <button className="lv-assign-x" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
</div>
<div className="lv-assign-list">
{assignees.length === 0 ? (
<div className="lv-assign-empty">No team members available.</div>
) : matches.length === 0 ? (
<div className="lv-assign-empty">No members match {q}.</div>
) : matches.map((a) => {
const current = lead.assignee?.id === a.id || lead.assignee?.name === a.name;
return (
<button key={a.id} className={`lv-assign-opt ${current ? "current" : ""}`} onClick={() => onPick(lead, a)}>
<Avatar initials={a.initials} size={30} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
<span className="lv-assign-name">{a.name}</span>
{current && <Icon name="check" size={16} />}
</button>
);
})}
</div>
</div>
</Modal>
);
}
/* ---------------------------------------------------------- */
/* Verification detail popup */
/* ---------------------------------------------------------- */
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
function VerifyDetail({ lead, onClose, onVerify }: { lead: VLead | null; onClose: () => void; onVerify: (l: VLead) => void }) {
const toast = useToast();
if (!lead) return null;
const meta = V_STATUS_META[lead.status];
const activity = buildActivity(lead);
const call = () => {
if (!lead.phone) { toast.push({ tone: "error", title: "No phone", desc: "This lead has no phone number." }); return; }
window.location.href = `tel:${lead.phone.replace(/[^\d+]/g, "")}`;
};
const verify = () => { onVerify(lead); onClose(); };
return (
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
footer={<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn icon="check-circle">Verify Lead</Btn>
<Btn variant="ghost" icon="phone" onClick={call}>Call</Btn>
<Btn icon="check-circle" onClick={verify} disabled={!allowed(lead.status).verify}>Verify Lead</Btn>
</>}
>
<div className="lv-detail">