"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; assignees: Assignee[]; /** Hydrate a single record's detail (notes, activity, verifiedAt) — synthesized in mock mode. */ getVerification: (lead: VLead) => Promise; verify: (lead: VLead, notes?: string) => Promise; markUnverified: (lead: VLead, reason?: string) => Promise; assign: (lead: VLead, assigneeId: string) => Promise; reassign: (lead: VLead, assigneeId?: string) => Promise; moveToPending: (lead: VLead) => Promise; 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 = { verified: 0, in_progress: 0, assigned: 0, pending: 0, unverified: 0 }; const SUB_STATUS_DEFAULT: Record = { 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((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(() => seedVLeads); const counts = useMemo(() => { const m = { ...EMPTY_COUNTS }; for (const l of leads) m[l.status] += 1; return m; }, [leads]); const assignees = useMemo( () => V_ASSIGNEES.map((name) => ({ id: name, name, initials: initialsOf(name) })), [], ); const patch = useCallback((lead: VLead, next: Partial) => { 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 { 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 | VerificationRowDTO[]>("crm.leadVerification.search", { perPage: 100 }); const statsQ = useQuery>>("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(() => (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>>({}); 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, action: string, variables: Record) => { 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 => { const dto = await sdk.query("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(); }