From d44cacb7783432f0830cb91e01b7fe6b45f05427 Mon Sep 17 00:00:00 2001 From: abe-kap Date: Wed, 22 Jul 2026 17:19:23 -0400 Subject: [PATCH] feat(projects): add the Projects CRM module --- src/app/dashboard/dashboard.css | 37 +++- src/components/dashboard/dashboard.tsx | 2 + src/components/dashboard/projects-data.ts | 148 +++++++++++++ src/components/dashboard/projects.tsx | 258 ++++++++++++++++++++++ src/components/dashboard/sidebar.tsx | 1 + src/components/dashboard/ui.tsx | 15 +- src/lib/projects-api.ts | 201 +++++++++++++++++ 7 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 src/components/dashboard/projects-data.ts create mode 100644 src/components/dashboard/projects.tsx create mode 100644 src/lib/projects-api.ts diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index de8381a..da1520c 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -432,8 +432,10 @@ .dash-root .ds-modal.size-sm { max-width: 400px; } .dash-root .ds-modal.size-md { max-width: 540px; } .dash-root .ds-modal.size-lg { max-width: 760px; } + .dash-root .ds-modal.size-xl { max-width: 980px; } .dash-root .ds-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 20px 14px; border-bottom: 1px solid var(--border); } .dash-root .ds-modal-head-l { display: flex; gap: 12px; align-items: center; } + .dash-root .ds-modal-head-r { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } .dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); } .dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; } .dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; } @@ -907,7 +909,7 @@ .dash-root .tm .ds-btn.v-primary { background: rgba(253, 169, 19, 0.92); color: #fff; border-radius: 9.132px; box-shadow: 0 8px 20px -10px rgba(253, 169, 19, 0.75); } .dash-root .tm .ds-btn.v-primary svg { color: #fff; } .dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); } - .dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + .dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } .dash-root .tm-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 44px; padding: 0 13px; border-radius: 13px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); transition: 0.14s; } .dash-root .tm-search:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px var(--ring); color: var(--orange); } .dash-root .tm-search .ds-input { flex: 1; } @@ -1139,5 +1141,36 @@ .dash-root .ai-caps { grid-template-columns: 1fr; } .dash-root .ai-bubble { max-width: 86%; } .dash-root .ai-view { height: calc(100vh - 150px); } + .dash-root .proj-row { grid-template-columns: 1fr !important; row-gap: 8px; padding: 14px 16px; } + .dash-root .proj-head { display: none; } + .dash-root .proj-c-act { justify-self: end; } } - \ No newline at end of file + + /* ---- Projects (Construction + Pipeline Leads) ---- */ + .dash-root .proj-toolbar .tm-tabs { flex: 0 0 auto; } + .dash-root .proj-toolbar .tm-chips { flex: 0 0 auto; margin-left: auto; } + .dash-root .proj-table { display: flex; flex-direction: column; } + .dash-root .proj-row { display: grid; align-items: center; gap: 14px; padding: 13px 18px; border-bottom: 1px solid var(--border); } + .dash-root .proj-row:last-child { border-bottom: 0; } + .dash-root .proj-head { color: var(--faint); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-align: center; } + .dash-root .proj-row:not(.proj-head):hover { background: color-mix(in srgb, var(--panel-2) 60%, transparent); } + .dash-root .proj-lead { min-width: 0; } + .dash-root .proj-lead-name { font-size: 13.5px; font-weight: 700; } + .dash-root .proj-lead-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .dash-root .proj-text { font-size: 13px; color: var(--text-2); text-align: center; } + .dash-root .proj-c-num { text-align: center; } + .dash-root .proj-row .ds-pill { justify-self: center; justify-content: center; } + .dash-root .proj-c-act { display: flex; justify-content: center; align-items: center; gap: 6px; position: relative; } + .dash-root .proj-cd-search { margin-bottom: 14px; } + .dash-root .proj-cd-table { margin-bottom: 14px; } + .dash-root .proj-progress { display: flex; align-items: center; gap: 8px; } + .dash-root .proj-progress-track { flex: 1; display: block; height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; } + .dash-root .proj-progress-fill { display: block; height: 100%; border-radius: 99px; background: var(--orange); transition: width 0.5s cubic-bezier(0.2,0.7,0.2,1); } + .dash-root .proj-progress b { font-size: 12px; font-weight: 700; color: var(--text-2); flex: 0 0 auto; } + .dash-root .proj-health { font-weight: 800; text-align: center; } + .dash-root .proj-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 54px 20px; color: var(--muted); text-align: center; } + .dash-root .proj-foot { display: flex; align-items: center; justify-content: space-between; padding: 16px 4px 0; font-size: 12.5px; color: var(--muted); } + .dash-root .proj-foot b { color: var(--text); font-size: 15px; font-weight: 800; } + .dash-root .proj-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; } + .dash-root .proj-detail-row span { color: var(--muted); } + .dash-root .proj-detail-row b { color: var(--text); font-weight: 700; } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index d052a4e..57feec3 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -17,6 +17,7 @@ import { AiAssistant } from "./ai-assistant"; import { TeamManagement } from "./team-management"; import { Messenger } from "./messenger"; import { Inbox } from "./inbox"; +import { Projects } from "./projects"; import "../../app/dashboard/dashboard.css"; export function Dashboard() { @@ -50,6 +51,7 @@ export function Dashboard() { : active === "messenger" ? : active === "inbox" ? : active === "team" ? + : active === "projects" ? : } diff --git a/src/components/dashboard/projects-data.ts b/src/components/dashboard/projects-data.ts new file mode 100644 index 0000000..f53e31d --- /dev/null +++ b/src/components/dashboard/projects-data.ts @@ -0,0 +1,148 @@ +// ============================================================ +// LynkedUp Pro — Projects mock data (Construction jobs + Pipeline +// leads). Used only when the Shell isn't configured, so the demo +// stays fully interactive without a backend — mirrors team-data.ts. +// ============================================================ + +export type ConstructionStatus = "active" | "complete" | "stuck" | "followup"; + +export type ConstructionLead = { + id: string; + name: string; + address: string; + status: ConstructionStatus; + stage: string; + jobType: string; + agent: string; + progress: number; // 0-100 + health: number; // 0-100 + value: number; // dollars +}; + +// The first 8 rows mirror the reference design exactly (name, address, job +// type, stage, status, progress and health). The rest extend the set to 15 +// construction jobs with a realistic status/stage spread so the quick +// filters (Active/Complete/Stuck/Follow-up) all have something to show. +export const constructionLeads: ConstructionLead[] = [ + { id: "c1", name: "Derek Holloway", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 28500 }, + { id: "c2", name: "Brenda Castillo", address: "5501 Shady Brook Ln, Plano TX 75093", status: "active", stage: "New Lead", jobType: "Roof Inspection", agent: "Cody Tatum", progress: 14, health: 65, value: 15400 }, + { id: "c3", name: "Antonio Reyes", address: "1122 Custer Rd, Plano TX 75075", status: "active", stage: "New Lead", jobType: "Gutter Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 19200 }, + { id: "c4", name: "Sylvia Nguyen", address: "3308 Roundrock Trl, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Siding Repair", agent: "Cody Tatum", progress: 14, health: 65, value: 21900 }, + { id: "c5", name: "Raymond Osei", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 31200 }, + { id: "c6", name: "Carolyn Estrada", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Window Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 22400 }, + { id: "c7", name: "Marcus Tillman", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 26800 }, + { id: "c8", name: "Diane Kowalski", address: "815 Independence Pkwy, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 29500 }, + { id: "c9", name: "Felicia Grant", address: "9021 Legacy Dr, Plano TX 75024", status: "complete", stage: "Completed", jobType: "Gutter Replacement", agent: "Emma Wilson", progress: 100, health: 92, value: 18600 }, + { id: "c10", name: "Harold Jennings", address: "4477 Coit Rd, Plano TX 75075", status: "stuck", stage: "Permit Hold", jobType: "Siding Repair", agent: "Liam Foster", progress: 42, health: 38, value: 24800 }, + { id: "c11", name: "Yolanda Brooks", address: "6650 Parker Rd, Plano TX 75093", status: "followup", stage: "Awaiting Customer", jobType: "Window Replacement", agent: "Sophie Turner", progress: 55, health: 51, value: 20700 }, + { id: "c12", name: "Preston Wallace", address: "3312 Alma Dr, Plano TX 75075", status: "active", stage: "Scheduled", jobType: "Roof Repair", agent: "Cody Tatum", progress: 30, health: 70, value: 27500 }, + { id: "c13", name: "Nadia Ferreira", address: "8890 Independence Pkwy, Plano TX 75025", status: "complete", stage: "Completed", jobType: "Roof Inspection", agent: "Emma Wilson", progress: 100, health: 95, value: 16200 }, + { id: "c14", name: "Louis Abernathy", address: "2200 K Ave, Plano TX 75074", status: "stuck", stage: "Material Delay", jobType: "Roof Replacement", agent: "Liam Foster", progress: 60, health: 44, value: 24900 }, + { id: "c15", name: "Grace Delgado", address: "7301 Ohio Dr, Plano TX 75093", status: "active", stage: "In Progress", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 68, health: 78, value: 51600 }, +]; + +export type CollectionStatus = "paid" | "pending" | "overdue"; + +export type CollectionRecord = { + id: string; + name: string; + address: string; + referenceId: string; + date: string; // YYYY-MM-DD + type: string; // "Deposit — 30%", "Progress Payment — 40%", "Final Payment — 30%" + status: CollectionStatus; + amount: number; // dollars +}; + +// Deterministic 3-installment payment schedule per construction job (deposit / +// progress / final), so "Total Collected" has something real to show without a +// billing backend. Which installments are PAID vs PENDING/OVERDUE follows the +// job's own status — a completed job is fully paid, a stuck job has an overdue +// progress payment, etc. +const INSTALLMENTS = [ + { label: "Deposit — 30%", pct: 0.3 }, + { label: "Progress Payment — 40%", pct: 0.4 }, + { label: "Final Payment — 30%", pct: 0.3 }, +] as const; + +function statusFor(jobStatus: ConstructionStatus, i: number): CollectionStatus { + if (jobStatus === "complete") return "paid"; + if (i === 0) return "paid"; // deposit is always collected up front + if (jobStatus === "stuck" && i === 1) return "overdue"; + return "pending"; +} + +export function collectionsFor(lead: ConstructionLead, leadIndex: number): CollectionRecord[] { + const deposit = Math.round(lead.value * INSTALLMENTS[0].pct); + const progress = Math.round(lead.value * INSTALLMENTS[1].pct); + const final = lead.value - deposit - progress; // remainder avoids rounding drift + const amounts = [deposit, progress, final]; + const ref = `PRJ-2026-${String(leadIndex + 1).padStart(3, "0")}`; + return INSTALLMENTS.map((inst, i) => { + const month = ((leadIndex * 2 + i * 2) % 12) + 1; + const day = ((leadIndex * 5 + i * 7) % 27) + 1; + return { + id: `${lead.id}-r${i + 1}`, + name: lead.name, + address: lead.address, + referenceId: `${ref}-R${i + 1}`, + date: `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`, + type: inst.label, + status: statusFor(lead.status, i), + amount: amounts[i], + }; + }); +} + +export type PipelineLead = { + id: string; + name: string; + address: string; + jobType: string; + stage: string; + agent: string; + createdAgo: string; +}; + +const FIRST_NAMES = [ + "Wesley", "Ivy", "Corey", "Renata", "Miles", "Paula", "Jasper", "Dana", + "Terrence", "Alina", "Grant", "Bethany", "Owen", "Marisol", "Kurt", + "Tanya", "Reggie", "Selena", "Blake", "Vivian", "Colton", "Priya", + "Gerald", "Fiona", +]; +const LAST_NAMES = [ + "Whitfield", "Caldwell", "Rourke", "Sanborn", "Delacroix", "Winters", + "Blackwood", "Herrera", "Sweeney", "Okafor", "Lindgren", "Pruitt", + "Castellano", "Marsh", "Yaeger", "Doyle", "Kowalczyk", "Beaumont", + "Ashworth", "Nakamura", "Villanueva", "Prescott", "Hutchins", + "Loomis", "Stanhope", "Everly", "Boone", +]; +const STREETS = [ + "Independence Pkwy", "Legacy Dr", "Coit Rd", "Parker Rd", "Alma Dr", + "K Ave", "Ohio Dr", "Preston Rd", "Spring Creek Pkwy", "Custer Rd", + "Shady Brook Ln", "Roundrock Trl", "Ridgeview Dr", "Chisholm Trl", + "Los Rios Blvd", +]; +const ZIPS = ["75023", "75024", "75025", "75074", "75075", "75093"]; +const LEAD_JOB_TYPES = ["Roof Replacement", "Roof Inspection", "Gutter Replacement", "Siding Repair", "Window Replacement", "Roof Repair"]; +const LEAD_STAGES = ["New Inquiry", "Contacted", "Qualifying", "Quote Requested", "Nurture"]; +const LEAD_AGENTS = ["Cody Tatum", "Emma Wilson", "Liam Foster", "Sophie Turner", "Chloe Adams", "Unassigned"]; + +// Deterministic (no Math.random/Date.now) so server- and client-render match. +export const pipelineLeads: PipelineLead[] = Array.from({ length: 45 }, (_, i) => { + const first = FIRST_NAMES[i % FIRST_NAMES.length]; + const last = LAST_NAMES[(i * 7) % LAST_NAMES.length]; + const streetNum = 1000 + ((i * 137) % 8900); + const street = STREETS[(i * 3) % STREETS.length]; + const zip = ZIPS[i % ZIPS.length]; + const daysAgo = ((i * 3) % 21) + 1; + return { + id: `p${i + 1}`, + name: `${first} ${last}`, + address: `${streetNum} ${street}, Plano TX ${zip}`, + jobType: LEAD_JOB_TYPES[i % LEAD_JOB_TYPES.length], + stage: LEAD_STAGES[(i * 2) % LEAD_STAGES.length], + agent: LEAD_AGENTS[(i * 5) % LEAD_AGENTS.length], + createdAgo: daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`, + }; +}); diff --git a/src/components/dashboard/projects.tsx b/src/components/dashboard/projects.tsx new file mode 100644 index 0000000..634d6f8 --- /dev/null +++ b/src/components/dashboard/projects.tsx @@ -0,0 +1,258 @@ +"use client"; + +// ============================================================ +// Projects — Construction jobs + Pipeline leads. +// Data comes from useProjectsData(): the local mock when the +// Shell isn't configured, or the live be-crm data door +// (crm.project.*) when it is. be-crm's Project model is generic +// (name/status/value/owner/address/dates) — mock mode additionally +// carries stage, job type, progress and health, which the live +// table simply doesn't render (nothing backs them). +// ============================================================ + +import { useMemo, useState } from "react"; +import { Btn, Icon, Modal, PageHead, Pill } from "./ui"; +import { + QUICK_FILTERS, STATUS_LABEL, STATUS_TONE, useProjectsData, + type UiCollectionRecord, type UiProject, type UiProjectStatus, +} from "@/lib/projects-api"; + +const money = (n: number) => `$${Math.round(n).toLocaleString()}`; +// The collections ledger shows cents (matches invoice-style amounts); the rest of the page rounds to whole dollars. +const moneyExact = (n: number) => `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +const COLLECTION_STATUS_LABEL: Record = { paid: "Paid", pending: "Pending", overdue: "Overdue" }; +const COLLECTION_STATUS_TONE: Record = { paid: "green", pending: "orange", overdue: "red" }; + +export function Projects() { + const data = useProjectsData(); + const { projects, live } = data; + + const [tab, setTab] = useState<"construction" | "pipeline">("construction"); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | UiProjectStatus>("all"); + const [viewProject, setViewProject] = useState(null); + + const construction = useMemo(() => projects.filter((p) => !p.isLead), [projects]); + const pipeline = useMemo(() => projects.filter((p) => p.isLead), [projects]); + const budgetTotal = useMemo(() => construction.reduce((s, p) => s + (p.value ?? 0), 0), [construction]); + + const list = tab === "construction" ? construction : pipeline; + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return list.filter((p) => { + const matchesStatus = tab === "pipeline" || statusFilter === "all" || p.status === statusFilter; + const matchesQ = !q + || p.name.toLowerCase().includes(q) + || (p.address ?? "").toLowerCase().includes(q) + || (p.jobType ?? "").toLowerCase().includes(q) + || p.agent.toLowerCase().includes(q); + return matchesStatus && matchesQ; + }); + }, [list, statusFilter, query, tab]); + + // Mock construction (richest): Lead | Status | Stage | Job Type | Agent | Progress | Health | ⋯ + // Mock pipeline: Lead | Job Type | Stage | Agent | Created | ⋯ + // Live (either tab, leaner — only fields be-crm actually stores): Lead | Status | Owner | Value | Due date | ⋯ + const template = live + ? "minmax(220px,2.3fr) 110px 160px 120px 130px 48px" + : tab === "construction" + ? "minmax(220px,2.2fr) 104px 130px 150px 130px 130px 76px 48px" + : "minmax(220px,2.2fr) 150px 140px 130px 120px 48px"; + + return ( +
+ + + {data.error &&

Couldn't load projects: {data.error}

} + +
+
+ + +
+ +
+ + setQuery(e.target.value)} /> + {query && } +
+ + {tab === "construction" && ( +
+ + {QUICK_FILTERS.map((s) => ( + + ))} +
+ )} +
+ + {data.live && data.loading && projects.length === 0 ? ( +

Loading projects…

+ ) : filtered.length === 0 ? ( +
+ +

No {tab === "construction" ? "projects" : "leads"} match your search.

+ { setQuery(""); setStatusFilter("all"); }}>Clear filters +
+ ) : ( +
+
+ Lead + {live ? ( + <>StatusOwnerValueDue date + ) : tab === "construction" ? ( + <>StatusStageJob typeAgentProgressHealth + ) : ( + <>Job typeStageAgentCreated + )} + +
+ + {filtered.map((p) => ( +
+
+
{p.name}
+ {p.address &&
{p.address}
} +
+ + {live ? ( + <> + {STATUS_LABEL[p.status].toUpperCase()} + {p.agent} + {p.value != null ? money(p.value) : } + {p.dueDate ? new Date(p.dueDate).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : } + + ) : tab === "construction" ? ( + <> + {STATUS_LABEL[p.status].toUpperCase()} + {p.stage} + {p.jobType} + {p.agent} +
+ + {p.progress}% +
+ {p.health} + + ) : ( + <> + {p.jobType} + {p.stage} + {p.agent} + {p.createdAgo} + + )} + +
+ +
+
+ ))} +
+ )} + +
+ Showing {filtered.length} of {list.length} {tab === "construction" ? "projects" : "leads"} + {tab === "construction" && {money(budgetTotal)} total} +
+ + setViewProject(null)} /> +
+ ); +} + +/* ---------------------------------------------------------- */ +/* Collected details modal — payment ledger for one project */ +/* ---------------------------------------------------------- */ + +function CollectedDetailsModal({ project, onClose }: { project: UiProject | null; onClose: () => void }) { + const [query, setQuery] = useState(""); + const records = project?.collections ?? []; + const total = useMemo(() => records.reduce((s, r) => s + r.amount, 0), [records]); + if (!project) return null; + + const q = query.trim().toLowerCase(); + const filtered = records.filter((r) => + !q || r.name.toLowerCase().includes(q) || r.referenceId.toLowerCase().includes(q) || r.type.toLowerCase().includes(q)); + const netTotal = filtered.reduce((s, r) => s + r.amount, 0); + const projectName = project.name; + + function downloadCsv() { + const header = ["Name/Project", "Reference ID", "Date", "Type", "Status", "Amount"]; + const rows = filtered.map((r) => [r.name, r.referenceId, r.date, r.type, COLLECTION_STATUS_LABEL[r.status], r.amount.toFixed(2)]); + const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${projectName.replace(/\s+/g, "-").toLowerCase()}-collections.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( + Total: {moneyExact(total)}} + size="xl" + headerExtra={records.length > 0 && ( + + )} + > + {project.collections === null ? ( +

Collections aren't available yet for live projects — this needs a billing module in be-crm.

+ ) : records.length === 0 ? ( +

No payments collected yet for {project.name}.

+ ) : ( + <> +
+ + setQuery(e.target.value)} /> + {query && } +
+ +
+
+ Name / ProjectReference IDDateTypeStatusAmount +
+ {filtered.map((r) => ( +
+
+
{r.name}
+
{r.address}
+
+ {r.referenceId} + {r.date} + {r.type} + {COLLECTION_STATUS_LABEL[r.status].toUpperCase()} + {moneyExact(r.amount)} +
+ ))} +
+ +
+ Showing {filtered.length} record{filtered.length === 1 ? "" : "s"} + Net Total: {moneyExact(netTotal)} +
+ + )} +
+ ); +} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index e995cb0..a52ea7e 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -86,6 +86,7 @@ const NAV_PERMISSION: Record = { leads: "leads.manage", verify: "leads.manage", pipeline: "pipeline.manage", + projects: "pipeline.manage", estimates: "estimates.create", procanvas: "estimates.create", dispatch: "dispatch.manage", diff --git a/src/components/dashboard/ui.tsx b/src/components/dashboard/ui.tsx index 5b57735..62fc282 100644 --- a/src/components/dashboard/ui.tsx +++ b/src/components/dashboard/ui.tsx @@ -24,7 +24,7 @@ import { LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter, Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays, Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal, - UsersRound, type LucideIcon, + UsersRound, Download, type LucideIcon, } from "lucide-react"; /* ---------------------------------------------------------- */ @@ -49,7 +49,7 @@ const ICONS: Record = { storm: CloudLightning, territory: MapIcon, procanvas: PenTool, estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy, subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles, - team: UsersRound, dots: MoreHorizontal, + team: UsersRound, dots: MoreHorizontal, download: Download, }; export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) { @@ -217,9 +217,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le /* Modal */ /* ---------------------------------------------------------- */ -export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: { - open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string; - children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg"; +export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: { + open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string; + children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode; }) { const titleId = useId(); // Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport, @@ -246,7 +246,10 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer, {subtitle &&

{subtitle}

} - +
+ {headerExtra} + +
{children}
{footer &&
{footer}
} diff --git a/src/lib/projects-api.ts b/src/lib/projects-api.ts new file mode 100644 index 0000000..44ff3c5 --- /dev/null +++ b/src/lib/projects-api.ts @@ -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; + 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(); +}