"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)}
)}
); }