"use client"; // Projects 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.project.*), behind one interface so the component is mode-agnostic. // Mirrors team-api.ts. // // Live contract (be-crm): // query crm.project.list { perPage } -> { items: ProjectDTO[], meta } // cmd crm.project.create { name, status?, value?, address?, dueDate?, ... } // cmd crm.project.setStatus { id, status } // cmd crm.project.remove { id } // // be-crm's Project model is generic (name/status/value/owner/address/dates) — // it has no stage, job type, progress or health score. Those are mock-only // fields (undefined in live mode); the Projects screen renders a leaner // column set when `live` is true. import { useCallback, useMemo, useState } from "react"; import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; import { isShellConfigured } from "./appshell"; import { collectionsFor, constructionLeads, pipelineLeads, type CollectionRecord, type ConstructionStatus, } from "@/components/dashboard/projects-data"; export type UiCollectionRecord = CollectionRecord; /* ---- UI-facing shapes ---------------------------------------------------- */ // "lead" / "archived" round out the mock's four (active/complete/stuck/followup) // so every be-crm ProjectStatus has somewhere to map to. export type UiProjectStatus = ConstructionStatus | "lead" | "archived"; export interface UiProject { id: string; name: string; address: string | null; status: UiProjectStatus; stage: string | null; // mock only jobType: string | null; // mock only agent: string; progress: number | null; // mock only health: number | null; // mock only value: number | null; // dollars dueDate: string | null; createdAgo: string | null; // mock pipeline leads only collections: UiCollectionRecord[] | null; // mock only — no billing backend yet isLead: boolean; // true → "Pipeline Leads" tab } export interface NewProjectInput { name: string; status: UiProjectStatus; value?: number; // dollars } export interface ProjectsData { live: boolean; loading: boolean; error: string | null; projects: UiProject[]; create: (p: NewProjectInput) => Promise; setStatus: (id: string, status: UiProjectStatus) => Promise; remove: (id: string) => Promise; refetch: () => void; } export const STATUS_LABEL: Record = { lead: "Lead", active: "Active", complete: "Complete", stuck: "Stuck", followup: "Follow-up", archived: "Archived", }; export const STATUS_TONE: Record = { lead: "purple", active: "green", complete: "blue", stuck: "red", followup: "orange", archived: "muted", }; // Quick-filter chips only cover the four "in-flight job" statuses (mirrors the design). export const QUICK_FILTERS: UiProjectStatus[] = ["active", "complete", "stuck", "followup"]; /* ---- mock → UI ------------------------------------------------------------ */ function mockConstruction(c: (typeof constructionLeads)[number], index: number): UiProject { return { id: c.id, name: c.name, address: c.address, status: c.status, stage: c.stage, jobType: c.jobType, agent: c.agent, progress: c.progress, health: c.health, value: c.value, dueDate: null, createdAgo: null, collections: collectionsFor(c, index), isLead: false, }; } function mockPipeline(p: (typeof pipelineLeads)[number]): UiProject { return { id: p.id, name: p.name, address: p.address, status: "lead", stage: p.stage, jobType: p.jobType, agent: p.agent, progress: null, health: null, value: null, dueDate: null, createdAgo: p.createdAgo, collections: [], isLead: true, }; } /* ======================================================================== */ /* Mock implementation (no Shell configured) */ /* ======================================================================== */ let mockSeq = 1; function useMockProjects(): ProjectsData { const [projects, setProjects] = useState(() => [ ...constructionLeads.map(mockConstruction), ...pipelineLeads.map(mockPipeline), ]); const create = useCallback(async (p: NewProjectInput) => { const isLead = p.status === "lead"; const next: UiProject = { id: `new_${mockSeq++}`, name: p.name, address: null, status: p.status, stage: isLead ? "New Inquiry" : "New Lead", jobType: null, agent: "Unassigned", progress: isLead ? null : 0, health: isLead ? null : 70, value: isLead ? null : (p.value ?? null), dueDate: null, createdAgo: isLead ? "Just now" : null, collections: [], isLead, }; setProjects((l) => [next, ...l]); }, []); const setStatus = useCallback(async (id: string, status: UiProjectStatus) => { setProjects((l) => l.map((p) => (p.id === id ? { ...p, status, isLead: status === "lead" } : p))); }, []); const remove = useCallback(async (id: string) => { setProjects((l) => l.filter((p) => p.id !== id)); }, []); return { live: false, loading: false, error: null, projects, create, setStatus, remove, refetch: () => {} }; } /* ======================================================================== */ /* Live implementation (be-crm data door) */ /* ======================================================================== */ type BackendStatus = "lead" | "active" | "on_hold" | "won" | "lost" | "archived"; const BACKEND_TO_UI: Record = { lead: "lead", active: "active", on_hold: "stuck", won: "complete", lost: "followup", archived: "archived", }; const UI_TO_BACKEND: Record = { lead: "lead", active: "active", stuck: "on_hold", complete: "won", followup: "lost", archived: "archived", }; interface ProjectAddressDTO { line1?: string | null; city?: string | null; state?: string | null; postalCode?: string | null } interface ProjectDTO { id: string; name: string; status: BackendStatus; ownerPrincipalId: string | null; value: number | null; address: ProjectAddressDTO | null; dueDate: string | null; } interface MemberRef { principalId: string; displayName: string | null; jobTitle: string | null } const fmtAddress = (a: ProjectAddressDTO | null): string | null => { if (!a) return null; const cityState = [a.city, a.state].filter(Boolean).join(" "); const parts = [a.line1, [cityState, a.postalCode].filter(Boolean).join(" ")].filter(Boolean); return parts.length ? parts.join(", ") : null; }; function useLiveProjects(): ProjectsData { const { sdk } = useAppShell(); const listQ = useQuery<{ items: ProjectDTO[] }>("crm.project.list", { perPage: 100 }); // Resolve ownerPrincipalId → a real name for the "Owner" column (crm.project.list // only returns the raw id). const membersQ = useQuery<{ items: MemberRef[] }>("crm.team.member.search", { perPage: 100 }); const refetch = useCallback(() => { listQ.refetch(); membersQ.refetch(); }, [listQ, membersQ]); const cmd = useCallback(async (action: string, variables: Record) => { await sdk.command(action, variables); refetch(); }, [sdk, refetch]); const nameByPrincipal = useMemo(() => { const m = new Map(); for (const it of membersQ.data?.items ?? []) if (it.principalId) m.set(it.principalId, it.displayName || it.jobTitle || it.principalId); return m; }, [membersQ.data]); const projects: UiProject[] = useMemo(() => (listQ.data?.items ?? []).map((d) => ({ id: d.id, name: d.name, address: fmtAddress(d.address), status: BACKEND_TO_UI[d.status] ?? "active", stage: null, jobType: null, agent: d.ownerPrincipalId ? (nameByPrincipal.get(d.ownerPrincipalId) ?? "Unassigned") : "Unassigned", progress: null, health: null, value: d.value != null ? d.value / 100 : null, // minor units → dollars dueDate: d.dueDate, createdAgo: null, collections: null, isLead: d.status === "lead", })), [listQ.data, nameByPrincipal]); return { live: true, loading: listQ.loading || membersQ.loading, error: (listQ.error ?? membersQ.error)?.message ?? null, projects, create: (p) => cmd("crm.project.create", { name: p.name, status: UI_TO_BACKEND[p.status], ...(p.value != null ? { value: Math.round(p.value * 100) } : {}), }), setStatus: (id, status) => cmd("crm.project.setStatus", { id, status: UI_TO_BACKEND[status] }), remove: (id) => cmd("crm.project.remove", { id }), refetch, }; } /* ---- public hook: pick the implementation at module-config time --------- */ const SHELL = isShellConfigured(); export function useProjectsData(): ProjectsData { return SHELL ? useLiveProjects() : useMockProjects(); }