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:
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
// Leads data layer. Serves EITHER the local mock (when the Shell isn't configured — the
|
||||
// polished demo keeps working) OR the live be-crm data door (crm.lead.*), behind one
|
||||
// interface so the Leads board is mode-agnostic. Mirrors team-api.ts exactly.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.lead.search { query?, status?, priority?, source?, assigneeId?, page?, perPage? }
|
||||
// -> { items: LeadCardDTO[], meta }
|
||||
// query crm.lead.get { id } -> LeadDTO (full detail)
|
||||
// query crm.lead.stats {} -> { total, byStatus }
|
||||
// cmd crm.lead.create <payload> -> LeadDTO
|
||||
// cmd crm.lead.updateStatus { id, status, note? } -> LeadDTO
|
||||
// cmd crm.lead.assign { id, assigneeId | null } -> LeadDTO
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import { useUploadAttachment, useDownloadUrl, type UploadedAttachment } from "./media-api";
|
||||
import {
|
||||
LEADS as seedLeads, TOTAL_LEADS, REPS as seedReps,
|
||||
type Lead, type LeadStatus, type LeadPriority, type Phone, type Email, type Rep, type Attachment,
|
||||
} from "@/components/dashboard/leads-data";
|
||||
|
||||
/* ---- create payload (union of Quick + Full form; §6.3) ------------------ */
|
||||
|
||||
export interface CreateLeadInput {
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
phones: { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean }[];
|
||||
emails?: { address: string; primary?: boolean }[];
|
||||
property?: { address?: string; city?: string; state?: string; zip?: string; type?: string };
|
||||
photos?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string }[];
|
||||
job?: {
|
||||
source?: string; referralNote?: string; canvasserId?: string;
|
||||
leadType?: string; workType?: string; tradeType?: string;
|
||||
urgency?: "Standard" | "High" | "Emergency"; notes?: string;
|
||||
};
|
||||
insurance?: {
|
||||
company?: string; claimNumber?: string; claimStatus?: string;
|
||||
adjusterName?: string; adjusterPhone?: string; policyNumber?: string;
|
||||
};
|
||||
assignment?: { assigneeId?: string | null; priority?: "Low" | "Medium" | "High"; followUp?: string };
|
||||
}
|
||||
|
||||
/** Partial update patch — flat camelCase fields (matches the tested `crm.lead.update` #21). */
|
||||
export interface LeadPatch {
|
||||
firstName?: string; lastName?: string;
|
||||
priority?: string;
|
||||
propertyAddress?: string; propertyCity?: string; propertyState?: string; propertyZip?: string; propertyType?: string;
|
||||
source?: string; leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
|
||||
insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
|
||||
insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
|
||||
assignedToId?: string | null; followUpDate?: string;
|
||||
phones?: { number: string; type: string; primary?: boolean }[];
|
||||
emails?: { address: string; primary?: boolean }[];
|
||||
}
|
||||
|
||||
export interface LeadsData {
|
||||
live: boolean; loading: boolean; error: string | null;
|
||||
leads: Lead[];
|
||||
total: number;
|
||||
byStatus: Record<LeadStatus, number>;
|
||||
/** Rep / canvasser / assignee picker options. In live mode `id` is the member's principalId
|
||||
* (the value be-crm resolves assignee/canvasser references against), not the mock REP code. */
|
||||
reps: Rep[];
|
||||
/** Hydrate a single lead's full detail (list rows carry card-level fields only in live mode). */
|
||||
getLead: (lead: Lead) => Promise<Lead>;
|
||||
createLead: (input: CreateLeadInput) => Promise<void>;
|
||||
/** Upload a site photo → {contentRef,…}. Feed the results into `createLead`'s `photos`. */
|
||||
uploadPhoto: (file: File) => Promise<UploadedAttachment>;
|
||||
/** Mint a short-lived signed URL to render a stored photo by its contentRef. */
|
||||
getPhotoUrl: (contentRef: string, mime?: string) => Promise<string>;
|
||||
/** Attach an already-uploaded photo to an existing lead (Edit mode) — crm.lead.attachment.add. */
|
||||
addPhoto: (lead: Lead, attachment: UploadedAttachment) => Promise<void>;
|
||||
/** Detach a stored photo from a lead (Edit mode) — crm.lead.attachment.remove. */
|
||||
removePhoto: (lead: Lead, attachment: Attachment) => Promise<void>;
|
||||
/** Edit an existing lead (Edit button on the detail popup). */
|
||||
updateLead: (lead: Lead, patch: LeadPatch) => Promise<void>;
|
||||
updateStatus: (lead: Lead, status: LeadStatus, note?: string) => Promise<void>;
|
||||
assign: (lead: Lead, assigneeId: string | null) => Promise<void>;
|
||||
/** Push the lead into the Lead Verification queue (crm.lead.sendForVerification). Idempotent server-side. */
|
||||
sendForVerification: (lead: Lead) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/* ---- helpers ------------------------------------------------------------ */
|
||||
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#4f8cff,#2c5cff)",
|
||||
"linear-gradient(135deg,#b07bf2,#7b53e0)", "linear-gradient(135deg,#33c98a,#1fa46c)",
|
||||
"linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
||||
];
|
||||
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
|
||||
const initialsOf = (name: string) =>
|
||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
const firstNameOf = (name: string) => name.split(/\s+/)[0] ?? "";
|
||||
|
||||
const relTime = (iso?: string | null): string => {
|
||||
if (!iso) return "—";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
const diff = Date.now() - then;
|
||||
const day = 86_400_000;
|
||||
if (diff >= 0 && diff < 7 * day) {
|
||||
const d = Math.floor(diff / day);
|
||||
if (d <= 0) return "today";
|
||||
return `${d}d ago`;
|
||||
}
|
||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
||||
};
|
||||
const prettyDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
||||
};
|
||||
const lc = (p?: string | null): LeadPriority =>
|
||||
(String(p ?? "medium").toLowerCase() as LeadPriority);
|
||||
|
||||
const EMPTY_BY_STATUS: Record<LeadStatus, number> = { new: 0, contacted: 0, appointed: 0, closed: 0 };
|
||||
|
||||
/* ---- be-crm DTO types (defensive: backend is greenfield) ---------------- */
|
||||
|
||||
interface LeadCardDTO {
|
||||
id: string; code: string; name: string; initials?: string; gradient?: string;
|
||||
priority?: string; status?: LeadStatus; tag?: string;
|
||||
primaryPhone?: string; propertyAddress?: string; propertyCity?: string; propertyState?: string;
|
||||
source?: string; canvasserName?: string; updatedAt?: string;
|
||||
// Set once the lead has an associated verification record (blocks a duplicate send).
|
||||
verificationId?: string | null; verificationStatus?: string | null;
|
||||
}
|
||||
interface LeadDTO extends LeadCardDTO {
|
||||
phones?: Phone[]; emails?: Email[];
|
||||
propertyZip?: string; propertyType?: string;
|
||||
leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
|
||||
referralNote?: string; canvasserId?: string;
|
||||
insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
|
||||
insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
|
||||
assignedToName?: string; createdByName?: string; followUpDate?: string; createdAt?: string;
|
||||
assignedToId?: string | null; createdById?: string | null;
|
||||
stormZone?: string; stormDate?: string; stormDetail?: string;
|
||||
// assignee / creator can come back under several field names depending on the DTO projection.
|
||||
// The object form carries an `id` (be-crm currently echoes the principalId as `name`, unresolved).
|
||||
assignedTo?: string | { id?: string; name?: string; displayName?: string } | null;
|
||||
assignee?: string | { id?: string; name?: string; displayName?: string } | null;
|
||||
createdBy?: string | { id?: string; name?: string; displayName?: string } | null;
|
||||
creatorName?: string;
|
||||
// site photos — LeadDTO detail projection carries attachments[] (§6.2)
|
||||
attachments?: Attachment[];
|
||||
// tolerate an already-grouped detail shape too
|
||||
property?: Lead["property"]; job?: Partial<Lead["job"]>;
|
||||
insurance?: Partial<Lead["insurance"]>; assignment?: Partial<Lead["assignment"]>;
|
||||
storm?: Partial<Lead["storm"]>;
|
||||
}
|
||||
|
||||
/** Pull a display name out of whatever shape the backend returns the assignee in. */
|
||||
function nameOf(v: string | { name?: string; displayName?: string } | null | undefined): string {
|
||||
if (!v) return "";
|
||||
if (typeof v === "string") return v;
|
||||
return v.displayName?.trim() || v.name?.trim() || "";
|
||||
}
|
||||
|
||||
/** Pull the member id out of an object-shaped member reference (string refs carry no id). */
|
||||
function idOf(v: string | { id?: string } | null | undefined): string {
|
||||
if (!v || typeof v === "string") return "";
|
||||
return v.id?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Resolve a member reference to a display name. be-crm currently echoes the raw principalId
|
||||
* as the `name`, so prefer the local reps list (principalId → real name); fall back to a
|
||||
* backend name only when it isn't just the id, then to the id itself. */
|
||||
function resolveMemberName(id: string, backendName: string, reps: Rep[]): string {
|
||||
const rep = id ? reps.find((r) => r.id === id) : undefined;
|
||||
if (rep) return rep.name;
|
||||
if (backendName && backendName !== id) return backendName;
|
||||
return backendName || id;
|
||||
}
|
||||
|
||||
/* ---- DTO → UI Lead ------------------------------------------------------ */
|
||||
|
||||
function cardToLead(d: LeadCardDTO): Lead {
|
||||
const name = d.name ?? "";
|
||||
return {
|
||||
id: d.code || d.id,
|
||||
refId: d.id,
|
||||
verificationId: d.verificationId ?? (d.verificationStatus ? String(d.verificationStatus) : undefined) ?? undefined,
|
||||
initials: d.initials || initialsOf(name),
|
||||
name,
|
||||
gradient: d.gradient || gradientFor(d.id),
|
||||
priority: lc(d.priority),
|
||||
status: (d.status ?? "new") as LeadStatus,
|
||||
tag: d.tag ?? "Storm Zone",
|
||||
updated: relTime(d.updatedAt),
|
||||
setter: firstNameOf(d.canvasserName ?? ""),
|
||||
storm: { zone: "", date: "", detail: "" },
|
||||
phones: d.primaryPhone ? [{ number: d.primaryPhone, type: "Mobile", primary: true }] : [],
|
||||
emails: [],
|
||||
property: {
|
||||
address: d.propertyAddress ?? "", city: d.propertyCity ?? "",
|
||||
state: d.propertyState ?? "", zip: "", type: "",
|
||||
},
|
||||
job: {
|
||||
source: d.source ?? "", leadType: "", workType: "", tradeType: "",
|
||||
urgency: "", canvasser: d.canvasserName ?? "", notes: "",
|
||||
},
|
||||
insurance: { company: "", claimStatus: "", claimNumber: "", policyNumber: "", adjusterName: "", adjusterPhone: "" },
|
||||
assignment: { assignedTo: "", priority: "", followUp: "", createdBy: "", createdAt: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function detailToLead(d: LeadDTO, fallback: Lead, reps: Rep[]): Lead {
|
||||
const base = cardToLead(d);
|
||||
const assigneeId = d.assignedToId || idOf(d.assignedTo) || idOf(d.assignee) || "";
|
||||
const assigneeName = d.assignedToName || nameOf(d.assignedTo) || nameOf(d.assignee) || "";
|
||||
const creatorId = d.createdById || idOf(d.createdBy) || "";
|
||||
const creatorName = d.createdByName || d.creatorName || nameOf(d.createdBy) || "";
|
||||
return {
|
||||
...base,
|
||||
storm: d.storm
|
||||
? { zone: d.storm.zone ?? "", date: d.storm.date ?? "", detail: d.storm.detail ?? "" }
|
||||
: { zone: d.stormZone ?? fallback.storm.zone, date: d.stormDate ?? fallback.storm.date, detail: d.stormDetail ?? fallback.storm.detail },
|
||||
phones: d.phones?.length ? d.phones : base.phones.length ? base.phones : fallback.phones,
|
||||
emails: d.emails ?? [],
|
||||
attachments: d.attachments ?? fallback.attachments ?? [],
|
||||
property: d.property ?? {
|
||||
address: d.propertyAddress ?? "", city: d.propertyCity ?? "", state: d.propertyState ?? "",
|
||||
zip: d.propertyZip ?? "", type: d.propertyType ?? "",
|
||||
},
|
||||
job: {
|
||||
source: d.job?.source ?? d.source ?? "",
|
||||
leadType: d.job?.leadType ?? d.leadType ?? "",
|
||||
workType: d.job?.workType ?? d.workType ?? "",
|
||||
tradeType: d.job?.tradeType ?? d.tradeType ?? "",
|
||||
urgency: d.job?.urgency ?? d.urgency ?? "",
|
||||
canvasser: d.job?.canvasser ?? d.canvasserName ?? "",
|
||||
notes: d.job?.notes ?? d.jobNotes ?? "",
|
||||
},
|
||||
insurance: {
|
||||
company: d.insurance?.company ?? d.insuranceCompany ?? "",
|
||||
claimStatus: d.insurance?.claimStatus ?? d.insuranceClaimStatus ?? "",
|
||||
claimNumber: d.insurance?.claimNumber ?? d.insuranceClaimNumber ?? "",
|
||||
policyNumber: d.insurance?.policyNumber ?? d.insurancePolicyNumber ?? "",
|
||||
adjusterName: d.insurance?.adjusterName ?? d.insuranceAdjusterName ?? "",
|
||||
adjusterPhone: d.insurance?.adjusterPhone ?? d.insuranceAdjusterPhone ?? "",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: d.assignment?.assignedTo
|
||||
|| resolveMemberName(assigneeId, assigneeName, reps)
|
||||
|| "Unassigned",
|
||||
priority: d.assignment?.priority ?? (d.priority ?? ""),
|
||||
followUp: d.assignment?.followUp ?? prettyDate(d.followUpDate),
|
||||
createdBy: d.assignment?.createdBy
|
||||
|| resolveMemberName(creatorId, creatorName, reps),
|
||||
createdAt: d.assignment?.createdAt ?? prettyDate(d.createdAt),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Mock implementation (no Shell configured) */
|
||||
/* ======================================================================== */
|
||||
|
||||
function useMockLeads(): LeadsData {
|
||||
const [leads, setLeads] = useState<Lead[]>(() => seedLeads);
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
const m = { ...EMPTY_BY_STATUS };
|
||||
for (const l of leads) m[l.status] += 1;
|
||||
return m;
|
||||
}, [leads]);
|
||||
|
||||
const getLead = useCallback(async (lead: Lead) => lead, []);
|
||||
|
||||
const createLead = useCallback(async (input: CreateLeadInput) => {
|
||||
const name = `${input.firstName} ${input.lastName ?? ""}`.trim();
|
||||
const phones: Phone[] = input.phones
|
||||
.filter((p) => p.number.trim())
|
||||
.map((p, i) => ({ number: p.number, type: p.type, primary: p.primary ?? i === 0 }));
|
||||
const seq = seedLeads.length + leads.length + 1;
|
||||
const lead: Lead = {
|
||||
id: `SAL-${String(seq).padStart(3, "0")}`,
|
||||
initials: initialsOf(name),
|
||||
name,
|
||||
gradient: gradientFor(name),
|
||||
priority: lc(input.assignment?.priority),
|
||||
status: "new",
|
||||
tag: "Storm Zone",
|
||||
updated: "today",
|
||||
setter: firstNameOf(input.job?.canvasserId ?? ""),
|
||||
storm: { zone: "", date: "", detail: "" },
|
||||
phones,
|
||||
emails: (input.emails ?? []).filter((e) => e.address.trim()).map((e, i) => ({ address: e.address, primary: e.primary ?? i === 0 })),
|
||||
attachments: input.photos ?? [],
|
||||
property: {
|
||||
address: input.property?.address ?? "", city: input.property?.city ?? "",
|
||||
state: input.property?.state ?? "TX", zip: input.property?.zip ?? "", type: input.property?.type ?? "",
|
||||
},
|
||||
job: {
|
||||
source: input.job?.source ?? "", leadType: input.job?.leadType ?? "",
|
||||
workType: input.job?.workType ?? "", tradeType: input.job?.tradeType ?? "",
|
||||
urgency: input.job?.urgency ?? "Standard", canvasser: input.job?.canvasserId ?? "", notes: input.job?.notes ?? "",
|
||||
},
|
||||
insurance: {
|
||||
company: input.insurance?.company ?? "", claimStatus: input.insurance?.claimStatus ?? "",
|
||||
claimNumber: input.insurance?.claimNumber ?? "", policyNumber: input.insurance?.policyNumber ?? "",
|
||||
adjusterName: input.insurance?.adjusterName ?? "", adjusterPhone: input.insurance?.adjusterPhone ?? "",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: input.assignment?.assigneeId ?? "Unassigned",
|
||||
priority: input.assignment?.priority ?? "Medium",
|
||||
followUp: input.assignment?.followUp || "—",
|
||||
createdBy: "You", createdAt: "today",
|
||||
},
|
||||
};
|
||||
setLeads((l) => [lead, ...l]);
|
||||
}, [leads.length]);
|
||||
|
||||
const updateLead = useCallback(async (lead: Lead, patch: LeadPatch) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? applyPatch(x, patch) : x)));
|
||||
}, []);
|
||||
const updateStatus = useCallback(async (lead: Lead, status: LeadStatus) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, status } : x)));
|
||||
}, []);
|
||||
const assign = useCallback(async (lead: Lead, assigneeId: string | null) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, assignment: { ...x.assignment, assignedTo: assigneeId ?? "Unassigned" } } : x)));
|
||||
}, []);
|
||||
// No shared store between the mock leads/verify hooks, so this is a no-op in demo mode
|
||||
// (the component still toasts success). Live mode does the real intake.
|
||||
const sendForVerification = useCallback(async (_lead: Lead) => {}, []);
|
||||
|
||||
// Demo mode has no storage door — keep the bytes in-browser via an object URL so the
|
||||
// uploaded photo still renders in the detail popup. `getPhotoUrl` passes it straight through.
|
||||
const uploadPhoto = useCallback(async (file: File): Promise<UploadedAttachment> => ({
|
||||
contentRef: URL.createObjectURL(file), mimeType: file.type || "image/jpeg", sizeBytes: file.size, filename: file.name,
|
||||
}), []);
|
||||
const getPhotoUrl = useCallback(async (contentRef: string) => contentRef, []);
|
||||
const addPhoto = useCallback(async (lead: Lead, attachment: UploadedAttachment) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: [...(x.attachments ?? []), attachment] } : x)));
|
||||
}, []);
|
||||
const removePhoto = useCallback(async (lead: Lead, attachment: Attachment) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: (x.attachments ?? []).filter((a) => a.contentRef !== attachment.contentRef) } : x)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
live: false, loading: false, error: null,
|
||||
leads, total: TOTAL_LEADS, byStatus, reps: seedReps,
|
||||
getLead, createLead, updateLead, updateStatus, assign, sendForVerification,
|
||||
uploadPhoto, getPhotoUrl, addPhoto, removePhoto, refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a flat LeadPatch onto the nested UI Lead (mock mode only). */
|
||||
function applyPatch(lead: Lead, p: LeadPatch): Lead {
|
||||
const name = p.firstName !== undefined || p.lastName !== undefined
|
||||
? `${p.firstName ?? lead.name.split(" ")[0] ?? ""} ${p.lastName ?? lead.name.split(" ").slice(1).join(" ")}`.trim()
|
||||
: lead.name;
|
||||
return {
|
||||
...lead,
|
||||
name,
|
||||
initials: name ? initialsOf(name) : lead.initials,
|
||||
priority: p.priority ? lc(p.priority) : lead.priority,
|
||||
phones: p.phones?.length ? p.phones.map((x, i) => ({ number: x.number, type: x.type as Phone["type"], primary: x.primary ?? i === 0 })) : lead.phones,
|
||||
emails: p.emails ? p.emails.map((x, i) => ({ address: x.address, primary: x.primary ?? i === 0 })) : lead.emails,
|
||||
property: {
|
||||
address: p.propertyAddress ?? lead.property.address,
|
||||
city: p.propertyCity ?? lead.property.city,
|
||||
state: p.propertyState ?? lead.property.state,
|
||||
zip: p.propertyZip ?? lead.property.zip,
|
||||
type: p.propertyType ?? lead.property.type,
|
||||
},
|
||||
job: {
|
||||
...lead.job,
|
||||
source: p.source ?? lead.job.source,
|
||||
leadType: p.leadType ?? lead.job.leadType,
|
||||
workType: p.workType ?? lead.job.workType,
|
||||
tradeType: p.tradeType ?? lead.job.tradeType,
|
||||
urgency: p.urgency ?? lead.job.urgency,
|
||||
notes: p.jobNotes ?? lead.job.notes,
|
||||
},
|
||||
insurance: {
|
||||
company: p.insuranceCompany ?? lead.insurance.company,
|
||||
claimStatus: p.insuranceClaimStatus ?? lead.insurance.claimStatus,
|
||||
claimNumber: p.insuranceClaimNumber ?? lead.insurance.claimNumber,
|
||||
policyNumber: p.insurancePolicyNumber ?? lead.insurance.policyNumber,
|
||||
adjusterName: p.insuranceAdjusterName ?? lead.insurance.adjusterName,
|
||||
adjusterPhone: p.insuranceAdjusterPhone ?? lead.insurance.adjusterPhone,
|
||||
},
|
||||
assignment: {
|
||||
...lead.assignment,
|
||||
assignedTo: p.assignedToId !== undefined ? (p.assignedToId ?? "Unassigned") : lead.assignment.assignedTo,
|
||||
priority: p.priority ?? lead.assignment.priority,
|
||||
followUp: p.followUpDate ?? lead.assignment.followUp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Live implementation (be-crm data door) */
|
||||
/* ======================================================================== */
|
||||
|
||||
interface Page<T> { items: T[]; meta?: { total?: number } }
|
||||
interface MemberDTO { id: string; principalId?: string; displayName?: string | null; firstName?: string | null; lastName?: string | null; jobTitle?: string | null; email?: string | null }
|
||||
|
||||
/* ---- error surfacing ---------------------------------------------------- */
|
||||
// appshell's DataClient throws an opaque "data command failed: <status>" and DROPS the response
|
||||
// body, so the backend's real error (e.g. the duplicate_lead guard) never reaches the UI. For
|
||||
// writes where the exact server message matters, `bffCommand` uses the same BFF transport but
|
||||
// parses the error body and throws the backend's own message.
|
||||
|
||||
const BFF_BASE = (process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell").replace(/\/$/, "");
|
||||
|
||||
// Observed be-crm error body (Fastify style):
|
||||
// { statusCode, error: "Conflict", message: "duplicate_lead",
|
||||
// detail: "A lead with the same primary phone and address already exists (SAL-010).",
|
||||
// duplicateOf: [{ id, code, name }] }
|
||||
// i.e. `message` is a CODE slug, `detail` is the human sentence, `duplicateOf` is an array.
|
||||
// Also tolerate a nested `{ error: { code, message } }` shape from other services.
|
||||
type DupRef = { id?: string; code?: string; name?: string };
|
||||
interface BackendError {
|
||||
statusCode?: number; code?: string; error?: string | BackendError;
|
||||
message?: string; detail?: string;
|
||||
duplicateOf?: DupRef[] | DupRef | null;
|
||||
details?: { duplicateOf?: DupRef[] | DupRef | null };
|
||||
}
|
||||
|
||||
function firstDup(body: BackendError): DupRef | null {
|
||||
const d = body.duplicateOf ?? body.details?.duplicateOf ?? null;
|
||||
if (!d) return null;
|
||||
return Array.isArray(d) ? (d[0] ?? null) : d;
|
||||
}
|
||||
|
||||
/** Show the backend's own human sentence verbatim; map a bare code slug otherwise. */
|
||||
function leadErrorText(code: string, human: string, body: BackendError, status: number): string {
|
||||
if (human) return human; // e.g. `detail` — exactly what the server said
|
||||
switch (code) { // slug with no human sentence
|
||||
case "duplicate_lead": {
|
||||
const who = firstDup(body)?.name || firstDup(body)?.code;
|
||||
return who
|
||||
? `A lead with the same primary phone and address already exists (${who}).`
|
||||
: "A lead with the same primary phone and address already exists.";
|
||||
}
|
||||
case "name_required": return "Enter the homeowner's first or last name.";
|
||||
case "invalid_assignee": return "The selected rep isn't a valid team member.";
|
||||
case "file_too_large": return "That photo is too large (max 25 MB).";
|
||||
default: return `Couldn’t complete the request (${status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
async function bffCommand<T>(action: string, variables: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${BFF_BASE}/data/command`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Idempotency-Key": globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2),
|
||||
},
|
||||
body: JSON.stringify({ action, variables }),
|
||||
});
|
||||
if (res.status === 401) throw new Error("SESSION_EXPIRED");
|
||||
if (res.ok) return (await res.json()) as T;
|
||||
let body: BackendError = {};
|
||||
try { body = (await res.json()) as BackendError; } catch { /* non-JSON error body */ }
|
||||
const inner = (body.error && typeof body.error === "object" ? body.error : body) as BackendError;
|
||||
const raw = inner.message ?? "";
|
||||
const isSlug = /^[a-z][a-z0-9_]*$/.test(raw); // "duplicate_lead" is a code, not a sentence
|
||||
const code = inner.code ?? (isSlug ? raw : "");
|
||||
const human = inner.detail ?? (isSlug ? "" : raw);
|
||||
const e = new Error(leadErrorText(code, human, inner, res.status));
|
||||
(e as unknown as { code?: string }).code = code;
|
||||
throw e;
|
||||
}
|
||||
|
||||
function useLiveLeads(): LeadsData {
|
||||
const { sdk } = useAppShell();
|
||||
const uploadPhoto = useUploadAttachment(); // presignUpload → PUT bytes → { contentRef, … }
|
||||
const getPhotoUrl = useDownloadUrl(); // presignDownload → signed URL
|
||||
// useQuery re-runs only on `action` change, so fetch a broad page and let the
|
||||
// board filter/search client-side (same shape team-api uses).
|
||||
const listQ = useQuery<Page<LeadCardDTO>>("crm.lead.search", { perPage: 100 });
|
||||
const statsQ = useQuery<{ total?: number; byStatus?: Partial<Record<LeadStatus, number>> }>("crm.lead.stats", {});
|
||||
// Rep/canvasser/assignee picker source (§6.6). be-crm resolves assignee/canvasser refs by
|
||||
// principalId, so that — not the opaque member id — is the value the form must send.
|
||||
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
||||
|
||||
const reps: Rep[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
|
||||
const name = m.displayName?.trim()
|
||||
|| [m.firstName, m.lastName].filter(Boolean).join(" ").trim()
|
||||
|| m.jobTitle?.trim()
|
||||
|| `Member ${(m.principalId ?? m.id).replace(/^pp_/, "").slice(0, 6)}`;
|
||||
return {
|
||||
id: m.principalId ?? m.id,
|
||||
initials: name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?",
|
||||
name,
|
||||
email: m.email ?? "",
|
||||
};
|
||||
}), [membersQ.data]);
|
||||
|
||||
const refetch = useCallback(() => { listQ.refetch(); statsQ.refetch(); }, [listQ, statsQ]);
|
||||
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
|
||||
await sdk.command(action, variables);
|
||||
refetch();
|
||||
}, [sdk, refetch]);
|
||||
|
||||
const leads = useMemo(() => (listQ.data?.items ?? []).map(cardToLead), [listQ.data]);
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
if (statsQ.data?.byStatus) return { ...EMPTY_BY_STATUS, ...statsQ.data.byStatus };
|
||||
const m = { ...EMPTY_BY_STATUS };
|
||||
for (const l of leads) m[l.status] += 1;
|
||||
return m;
|
||||
}, [statsQ.data, leads]);
|
||||
|
||||
const getLead = useCallback(async (lead: Lead): Promise<Lead> => {
|
||||
const dto = await sdk.query<LeadDTO>("crm.lead.get", { id: lead.refId ?? lead.id });
|
||||
return detailToLead(dto, lead, reps);
|
||||
}, [sdk, reps]);
|
||||
|
||||
return {
|
||||
live: true,
|
||||
loading: listQ.loading || statsQ.loading,
|
||||
error: (listQ.error ?? statsQ.error)?.message ?? null,
|
||||
leads,
|
||||
total: statsQ.data?.total ?? listQ.data?.meta?.total ?? leads.length,
|
||||
byStatus,
|
||||
reps,
|
||||
getLead,
|
||||
uploadPhoto,
|
||||
getPhotoUrl,
|
||||
addPhoto: async (lead, attachment) => {
|
||||
await sdk.command("crm.lead.attachment.add", { id: lead.refId ?? lead.id, attachment });
|
||||
},
|
||||
removePhoto: async (lead, attachment) => {
|
||||
await sdk.command("crm.lead.attachment.remove", { id: lead.refId ?? lead.id, attachmentId: attachment.id });
|
||||
},
|
||||
// Photos are NOT sent in the create payload — they persist via the dedicated
|
||||
// crm.lead.attachment.add command, keyed by the freshly-created lead's id (§ postman #24).
|
||||
createLead: async (input) => {
|
||||
const { photos, ...rest } = input;
|
||||
// bffCommand (not sdk.command) so the backend's duplicate_lead / validation message
|
||||
// reaches the toast instead of the SDK's opaque "data command failed: 400".
|
||||
const created = await bffCommand<{ id: string }>("crm.lead.create", rest);
|
||||
for (const attachment of photos ?? []) {
|
||||
await sdk.command("crm.lead.attachment.add", { id: created.id, attachment });
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
updateLead: (lead, patch) => cmd("crm.lead.update", { id: lead.refId ?? lead.id, patch }),
|
||||
updateStatus: (lead, status, note) => cmd("crm.lead.updateStatus", { id: lead.refId ?? lead.id, status, ...(note ? { note } : {}) }),
|
||||
assign: (lead, assigneeId) => cmd("crm.lead.assign", { id: lead.refId ?? lead.id, assigneeId }),
|
||||
// Push into the verification queue. Idempotent server-side (returns the existing verification
|
||||
// if the lead was already sent). Refetch so the lead's verificationId lands and the guard persists.
|
||||
sendForVerification: async (lead) => {
|
||||
await sdk.command("crm.lead.sendForVerification", { id: lead.refId ?? lead.id });
|
||||
refetch();
|
||||
},
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useLeadsData(): LeadsData {
|
||||
// SHELL is a build-time constant, so the same hook path runs every render (Rules-of-Hooks safe).
|
||||
return SHELL ? useLiveLeads() : useMockLeads();
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
// Lead Verification data layer. Serves EITHER the local mock (when the Shell isn't configured —
|
||||
// the demo keeps working) OR the live be-crm data door (crm.leadVerification.*), behind one
|
||||
// interface so the verification desk is mode-agnostic. Mirrors team-api.ts / leads-api.ts.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.leadVerification.search { query?, status?, source?, assigneeId?, page?, perPage? }
|
||||
// -> { items: VerificationRowDTO[], meta }
|
||||
// query crm.leadVerification.get { id } -> VerificationDTO (+ activities)
|
||||
// query crm.leadVerification.stats {} -> { verified, in_progress, assigned, pending, unverified }
|
||||
// cmd crm.leadVerification.verify { id, notes? } -> { verification, lead } (promotes → Lead)
|
||||
// cmd crm.leadVerification.markUnverified { id, reason? } -> VerificationDTO
|
||||
// cmd crm.leadVerification.assign { id, assigneeId } -> VerificationDTO
|
||||
// cmd crm.leadVerification.reassign { id, assigneeId? } -> VerificationDTO (→ in_progress)
|
||||
// cmd crm.leadVerification.moveToPending { id } -> VerificationDTO
|
||||
// query crm.team.member.search { perPage } (reused) -> assignee picker options
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import {
|
||||
V_LEADS as seedVLeads, V_ASSIGNEES,
|
||||
type VLead, type VStatus, type VActivity,
|
||||
} from "@/components/dashboard/verify-data";
|
||||
|
||||
export interface Assignee { id: string; name: string; initials: string }
|
||||
|
||||
export interface VerifyData {
|
||||
live: boolean; loading: boolean; error: string | null;
|
||||
leads: VLead[];
|
||||
total: number;
|
||||
counts: Record<VStatus, number>;
|
||||
assignees: Assignee[];
|
||||
/** Hydrate a single record's detail (notes, activity, verifiedAt) — synthesized in mock mode. */
|
||||
getVerification: (lead: VLead) => Promise<VLead>;
|
||||
verify: (lead: VLead, notes?: string) => Promise<void>;
|
||||
markUnverified: (lead: VLead, reason?: string) => Promise<void>;
|
||||
assign: (lead: VLead, assigneeId: string) => Promise<void>;
|
||||
reassign: (lead: VLead, assigneeId?: string) => Promise<void>;
|
||||
moveToPending: (lead: VLead) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/* ---- helpers ------------------------------------------------------------ */
|
||||
|
||||
const initialsOf = (name: string) =>
|
||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
|
||||
/** Resolve an assignee ref to a display name. be-crm echoes the raw principalId as the `name`,
|
||||
* so prefer the local assignee list (principalId → real name); fall back to a backend name only
|
||||
* when it isn't just the id, then to the id itself. Mirrors leads-api's resolveMemberName. */
|
||||
function resolveAssigneeName(id: string, backendName: string, assignees: Assignee[]): string {
|
||||
const a = id ? assignees.find((x) => x.id === id) : undefined;
|
||||
if (a) return a.name;
|
||||
if (backendName && backendName !== id) return backendName;
|
||||
return backendName || id;
|
||||
}
|
||||
const prettyDate = (iso?: string | null): string => {
|
||||
if (!iso) return "—";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
||||
};
|
||||
const prettyDateTime = (iso?: string | null): string | undefined => {
|
||||
if (!iso) return undefined;
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
return new Date(then).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const EMPTY_COUNTS: Record<VStatus, number> = { verified: 0, in_progress: 0, assigned: 0, pending: 0, unverified: 0 };
|
||||
|
||||
const SUB_STATUS_DEFAULT: Record<VStatus, string> = {
|
||||
verified: "Verified", in_progress: "Verifying Identity", assigned: "Assigned",
|
||||
pending: "Pending Review", unverified: "Unverified",
|
||||
};
|
||||
|
||||
/* ---- be-crm DTO types (defensive: backend is greenfield) ---------------- */
|
||||
|
||||
interface VerificationRowDTO {
|
||||
id: string; code: string; name: string; initials?: string;
|
||||
address?: string; phone?: string; source?: string;
|
||||
assignee?: { id?: string; initials?: string; name: string } | null;
|
||||
status?: VStatus; verification?: string; createdAt?: string;
|
||||
}
|
||||
interface ActivityDTO { text: string; occurredAt?: string; time?: string; actorLabel?: string; who?: string }
|
||||
interface VerificationDTO extends VerificationRowDTO {
|
||||
email?: string; notes?: string; verifiedAt?: string; activities?: ActivityDTO[];
|
||||
}
|
||||
|
||||
/* ---- DTO → UI VLead ----------------------------------------------------- */
|
||||
|
||||
function rowToVLead(d: VerificationRowDTO, assignees: Assignee[] = []): VLead {
|
||||
const status = (d.status ?? "pending") as VStatus;
|
||||
let assignee: VLead["assignee"] = null;
|
||||
if (d.assignee) {
|
||||
const id = d.assignee.id ?? "";
|
||||
const name = resolveAssigneeName(id, d.assignee.name, assignees);
|
||||
assignee = { id, initials: d.assignee.initials || initialsOf(name), name };
|
||||
}
|
||||
return {
|
||||
id: d.code || d.id,
|
||||
refId: d.id,
|
||||
initials: d.initials || (d.name ? d.name[0].toUpperCase() : "?"),
|
||||
name: d.name ?? "",
|
||||
address: d.address ?? "",
|
||||
phone: d.phone ?? "",
|
||||
source: d.source ?? "",
|
||||
assignee,
|
||||
status,
|
||||
verification: d.verification ?? SUB_STATUS_DEFAULT[status],
|
||||
created: prettyDate(d.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function detailToVLead(d: VerificationDTO, fallback: VLead, assignees: Assignee[] = []): VLead {
|
||||
const base = rowToVLead(d, assignees);
|
||||
return {
|
||||
...base,
|
||||
email: d.email,
|
||||
notes: d.notes,
|
||||
verifiedAt: prettyDateTime(d.verifiedAt),
|
||||
createdAt: prettyDateTime(d.createdAt) ?? fallback.createdAt,
|
||||
activity: d.activities?.length
|
||||
? d.activities.map<VActivity>((a) => ({
|
||||
text: a.text,
|
||||
time: prettyDateTime(a.occurredAt) ?? a.time ?? "",
|
||||
who: a.actorLabel ?? a.who ?? "System",
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Mock implementation (no Shell configured) */
|
||||
/* ======================================================================== */
|
||||
|
||||
function useMockVerify(): VerifyData {
|
||||
const [leads, setLeads] = useState<VLead[]>(() => seedVLeads);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m = { ...EMPTY_COUNTS };
|
||||
for (const l of leads) m[l.status] += 1;
|
||||
return m;
|
||||
}, [leads]);
|
||||
|
||||
const assignees = useMemo<Assignee[]>(
|
||||
() => V_ASSIGNEES.map((name) => ({ id: name, name, initials: initialsOf(name) })),
|
||||
[],
|
||||
);
|
||||
|
||||
const patch = useCallback((lead: VLead, next: Partial<VLead>) => {
|
||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, ...next } : x)));
|
||||
}, []);
|
||||
|
||||
const getVerification = useCallback(async (lead: VLead) => lead, []);
|
||||
const verify = useCallback(async (lead: VLead) => {
|
||||
patch(lead, { status: "verified", verification: "Verified" });
|
||||
}, [patch]);
|
||||
const markUnverified = useCallback(async (lead: VLead) => {
|
||||
patch(lead, { status: "unverified", verification: "Unverified" });
|
||||
}, [patch]);
|
||||
const assign = useCallback(async (lead: VLead, assigneeId: string) => {
|
||||
const a = assignees.find((x) => x.id === assigneeId);
|
||||
patch(lead, { status: "assigned", verification: "Assigned", assignee: a ? { id: a.id, initials: a.initials, name: a.name } : lead.assignee });
|
||||
}, [assignees, patch]);
|
||||
const reassign = useCallback(async (lead: VLead, assigneeId?: string) => {
|
||||
const a = assigneeId ? assignees.find((x) => x.id === assigneeId) : undefined;
|
||||
patch(lead, { status: "in_progress", verification: "Verifying Identity", ...(a ? { assignee: { id: a.id, initials: a.initials, name: a.name } } : {}) });
|
||||
}, [assignees, patch]);
|
||||
const moveToPending = useCallback(async (lead: VLead) => {
|
||||
patch(lead, { status: "pending", verification: "Pending Review" });
|
||||
}, [patch]);
|
||||
|
||||
return {
|
||||
live: false, loading: false, error: null,
|
||||
leads, total: leads.length, counts, assignees,
|
||||
getVerification, verify, markUnverified, assign, reassign, moveToPending, refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Live implementation (be-crm data door) */
|
||||
/* ======================================================================== */
|
||||
|
||||
interface Page<T> { items: T[]; meta?: { total?: number } }
|
||||
interface MemberDTO { id: string; principalId?: string; displayName?: string | null; firstName?: string | null; lastName?: string | null; jobTitle?: string | null }
|
||||
|
||||
function useLiveVerify(): VerifyData {
|
||||
const { sdk } = useAppShell();
|
||||
// A collection read may come back as { items, meta } OR as a bare array (§6) — tolerate both.
|
||||
const listQ = useQuery<Page<VerificationRowDTO> | VerificationRowDTO[]>("crm.leadVerification.search", { perPage: 100 });
|
||||
const statsQ = useQuery<Partial<Record<VStatus, number>>>("crm.leadVerification.stats", {});
|
||||
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
||||
|
||||
const refetch = useCallback(() => { listQ.refetch(); statsQ.refetch(); }, [listQ, statsQ]);
|
||||
|
||||
// be-crm resolves the assignee ref by principalId, so send that (not the opaque member id).
|
||||
// This list also lets us resolve an assignee id → real name for display (be-crm echoes the id).
|
||||
const assignees = useMemo<Assignee[]>(() => (membersQ.data?.items ?? []).map((m) => {
|
||||
const name = m.displayName?.trim()
|
||||
|| [m.firstName, m.lastName].filter(Boolean).join(" ").trim()
|
||||
|| m.jobTitle?.trim()
|
||||
|| `Member ${(m.principalId ?? m.id).slice(0, 6)}`;
|
||||
return { id: m.principalId ?? m.id, name, initials: initialsOf(name) };
|
||||
}), [membersQ.data]);
|
||||
|
||||
const rawItems = useMemo(
|
||||
() => (Array.isArray(listQ.data) ? listQ.data : listQ.data?.items ?? []),
|
||||
[listQ.data],
|
||||
);
|
||||
const base = useMemo(() => rawItems.map((d) => rowToVLead(d, assignees)), [rawItems, assignees]);
|
||||
|
||||
// Optimistic status overrides keyed by lead id. After a transition command we patch the row
|
||||
// locally so the Status column updates immediately, even if the server's search read lags
|
||||
// (read-after-write). An override always reflects the user's last action, so it stays correct
|
||||
// whether the refetch is stale or fresh — no reconciliation needed.
|
||||
const [overrides, setOverrides] = useState<Record<string, Partial<VLead>>>({});
|
||||
|
||||
const leads = useMemo(
|
||||
() => base.map((l) => (overrides[l.id] ? { ...l, ...overrides[l.id] } : l)),
|
||||
[base, overrides],
|
||||
);
|
||||
|
||||
// Run a transition command, then optimistically patch the row + refetch to reconcile.
|
||||
const mutate = useCallback(async (lead: VLead, patch: Partial<VLead>, action: string, variables: Record<string, unknown>) => {
|
||||
await sdk.command(action, variables);
|
||||
setOverrides((o) => ({ ...o, [lead.id]: { ...o[lead.id], ...patch } }));
|
||||
refetch();
|
||||
}, [sdk, refetch]);
|
||||
|
||||
// Derive tile counts from the (optimistically patched) rows so they move in lockstep with the
|
||||
// table. With perPage 100 this covers the loaded queue; a transition reflects immediately.
|
||||
const counts = useMemo(() => {
|
||||
const m = { ...EMPTY_COUNTS };
|
||||
for (const l of leads) m[l.status] += 1;
|
||||
return m;
|
||||
}, [leads]);
|
||||
|
||||
const getVerification = useCallback(async (lead: VLead): Promise<VLead> => {
|
||||
const dto = await sdk.query<VerificationDTO>("crm.leadVerification.get", { id: lead.refId ?? lead.id });
|
||||
return detailToVLead(dto, lead, assignees);
|
||||
}, [sdk, assignees]);
|
||||
|
||||
return {
|
||||
live: true,
|
||||
loading: listQ.loading || statsQ.loading,
|
||||
error: (listQ.error ?? statsQ.error)?.message ?? null,
|
||||
leads,
|
||||
total: (Array.isArray(listQ.data) ? undefined : listQ.data?.meta?.total) ?? leads.length,
|
||||
counts,
|
||||
assignees,
|
||||
getVerification,
|
||||
verify: (lead, notes) => mutate(lead, { status: "verified", verification: "Verified" },
|
||||
"crm.leadVerification.verify", { id: lead.refId ?? lead.id, ...(notes ? { notes } : {}) }),
|
||||
markUnverified: (lead, reason) => mutate(lead, { status: "unverified", verification: "Unverified" },
|
||||
"crm.leadVerification.markUnverified", { id: lead.refId ?? lead.id, ...(reason ? { reason } : {}) }),
|
||||
assign: (lead, assigneeId) => mutate(lead, { status: "assigned", verification: "Assigned" },
|
||||
"crm.leadVerification.assign", { id: lead.refId ?? lead.id, assigneeId }),
|
||||
reassign: (lead, assigneeId) => mutate(lead, { status: "in_progress", verification: "Verifying Identity" },
|
||||
"crm.leadVerification.reassign", { id: lead.refId ?? lead.id, ...(assigneeId ? { assigneeId } : {}) }),
|
||||
moveToPending: (lead) => mutate(lead, { status: "pending", verification: "Pending Review" },
|
||||
"crm.leadVerification.moveToPending", { id: lead.refId ?? lead.id }),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useVerifyData(): VerifyData {
|
||||
// SHELL is a build-time constant, so the same hook path runs every render (Rules-of-Hooks safe).
|
||||
return SHELL ? useLiveVerify() : useMockVerify();
|
||||
}
|
||||
Reference in New Issue
Block a user