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,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