forked from Goutam/lynkeduppro-crm
feat(projects): add the Projects CRM module
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"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<void>;
|
||||
setStatus: (id: string, status: UiProjectStatus) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const STATUS_LABEL: Record<UiProjectStatus, string> = {
|
||||
lead: "Lead", active: "Active", complete: "Complete", stuck: "Stuck", followup: "Follow-up", archived: "Archived",
|
||||
};
|
||||
export const STATUS_TONE: Record<UiProjectStatus, string> = {
|
||||
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<UiProject[]>(() => [
|
||||
...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<BackendStatus, UiProjectStatus> = {
|
||||
lead: "lead", active: "active", on_hold: "stuck", won: "complete", lost: "followup", archived: "archived",
|
||||
};
|
||||
const UI_TO_BACKEND: Record<UiProjectStatus, BackendStatus> = {
|
||||
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<string, unknown>) => {
|
||||
await sdk.command(action, variables);
|
||||
refetch();
|
||||
}, [sdk, refetch]);
|
||||
|
||||
const nameByPrincipal = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user