Merge pull request 'feat(projects): add the Projects CRM module' (#32) from feat/projects into goutamnextflow
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
@@ -18,6 +18,7 @@ import { TeamManagement } from "./team-management";
|
||||
import { MessengerSdk } from "./messenger-sdk";
|
||||
import { InboxSdk } from "./inbox-sdk";
|
||||
import { Settings } from "./settings";
|
||||
import { Projects } from "./projects";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { RealtimeProvider } from "@/lib/realtime";
|
||||
import { SmartGallery } from "./smart-gallery";
|
||||
@@ -88,6 +89,7 @@ export function Dashboard() {
|
||||
: active === "leads" ? <Leads />
|
||||
: active === "verify" ? <Verify />
|
||||
: active === "team" ? <TeamManagement />
|
||||
: active === "projects" ? <Projects />
|
||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||
</ToastProvider>
|
||||
</div>
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
});
|
||||
@@ -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<UiCollectionRecord["status"], string> = { paid: "Paid", pending: "Pending", overdue: "Overdue" };
|
||||
const COLLECTION_STATUS_TONE: Record<UiCollectionRecord["status"], string> = { 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<UiProject | null>(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 (
|
||||
<div className="view proj">
|
||||
<PageHead
|
||||
title="Projects"
|
||||
subtitle={`${construction.length} construction · ${pipeline.length} pipeline leads · ${money(budgetTotal)} budget`}
|
||||
/>
|
||||
|
||||
{data.error && <div className="card proj-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn't load projects: {data.error}</p></div>}
|
||||
|
||||
<div className="tm-toolbar proj-toolbar">
|
||||
<div className="tm-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "construction"} className={`tm-tab ${tab === "construction" ? "active" : ""}`} onClick={() => setTab("construction")}>
|
||||
<Icon name="owners" size={16} /> <span>Construction</span>
|
||||
<span className="tm-tab-badge">{construction.length}</span>
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === "pipeline"} className={`tm-tab ${tab === "pipeline" ? "active" : ""}`} onClick={() => setTab("pipeline")}>
|
||||
<Icon name="pipeline" size={16} /> <span>Pipeline Leads</span>
|
||||
<span className="tm-tab-badge">{pipeline.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tm-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
|
||||
{tab === "construction" && (
|
||||
<div className="tm-chips">
|
||||
<button className={`tm-chip ${statusFilter === "all" ? "on" : ""}`} onClick={() => setStatusFilter("all")}>
|
||||
All <i>{construction.length}</i>
|
||||
</button>
|
||||
{QUICK_FILTERS.map((s) => (
|
||||
<button key={s} className={`tm-chip ${statusFilter === s ? "on" : ""}`} style={{ ["--rc" as string]: `var(--${STATUS_TONE[s] === "muted" ? "faint" : STATUS_TONE[s]})` }} onClick={() => setStatusFilter(s)}>
|
||||
{STATUS_LABEL[s]} <i>{construction.filter((p) => p.status === s).length}</i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.live && data.loading && projects.length === 0 ? (
|
||||
<div className="card proj-empty"><span className="tm-empty-ic"><Icon name="owners" size={24} /></span><p>Loading projects…</p></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card proj-empty">
|
||||
<span className="tm-empty-ic"><Icon name="search" size={24} /></span>
|
||||
<p>No {tab === "construction" ? "projects" : "leads"} match your search.</p>
|
||||
<Btn variant="soft" size="sm" onClick={() => { setQuery(""); setStatusFilter("all"); }}>Clear filters</Btn>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card card-pad-0 proj-table">
|
||||
<div className="proj-row proj-head" style={{ gridTemplateColumns: template }}>
|
||||
<span>Lead</span>
|
||||
{live ? (
|
||||
<><span>Status</span><span>Owner</span><span className="proj-c-num">Value</span><span>Due date</span></>
|
||||
) : tab === "construction" ? (
|
||||
<><span>Status</span><span>Stage</span><span>Job type</span><span>Agent</span><span>Progress</span><span className="proj-c-num">Health</span></>
|
||||
) : (
|
||||
<><span>Job type</span><span>Stage</span><span>Agent</span><span>Created</span></>
|
||||
)}
|
||||
<span className="proj-c-act" />
|
||||
</div>
|
||||
|
||||
{filtered.map((p) => (
|
||||
<div className="proj-row" key={p.id} style={{ gridTemplateColumns: template }}>
|
||||
<div className="proj-lead">
|
||||
<div className="proj-lead-name">{p.name}</div>
|
||||
{p.address && <div className="proj-lead-sub">{p.address}</div>}
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
<>
|
||||
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
|
||||
<span className="proj-text">{p.agent}</span>
|
||||
<span className="proj-c-num">{p.value != null ? money(p.value) : <span className="tm-dash">—</span>}</span>
|
||||
<span className="proj-text">{p.dueDate ? new Date(p.dueDate).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : <span className="tm-dash">—</span>}</span>
|
||||
</>
|
||||
) : tab === "construction" ? (
|
||||
<>
|
||||
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
|
||||
<Pill tone="blue">{p.stage}</Pill>
|
||||
<span className="proj-text">{p.jobType}</span>
|
||||
<span className="proj-text">{p.agent}</span>
|
||||
<div className="proj-progress" title={`${p.progress}%`}>
|
||||
<span className="proj-progress-track"><span className="proj-progress-fill" style={{ width: `${p.progress ?? 0}%` }} /></span>
|
||||
<b>{p.progress}%</b>
|
||||
</div>
|
||||
<span className="proj-c-num proj-health">{p.health}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="proj-text">{p.jobType}</span>
|
||||
<Pill tone="blue">{p.stage}</Pill>
|
||||
<span className="proj-text">{p.agent}</span>
|
||||
<span className="proj-text">{p.createdAgo}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="proj-c-act">
|
||||
<button className="ds-iconbtn" aria-label={`View collections for ${p.name}`} onClick={() => setViewProject(p)}><Icon name="eye" size={17} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="proj-foot">
|
||||
<span>Showing {filtered.length} of {list.length} {tab === "construction" ? "projects" : "leads"}</span>
|
||||
{tab === "construction" && <b>{money(budgetTotal)} total</b>}
|
||||
</div>
|
||||
|
||||
<CollectedDetailsModal project={viewProject} onClose={() => setViewProject(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* 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 (
|
||||
<Modal
|
||||
open={!!project}
|
||||
onClose={onClose}
|
||||
title="Total Collected Details"
|
||||
subtitle={<>Total: <b>{moneyExact(total)}</b></>}
|
||||
size="xl"
|
||||
headerExtra={records.length > 0 && (
|
||||
<button className="ds-iconbtn" aria-label="Download CSV" onClick={downloadCsv}><Icon name="download" size={18} /></button>
|
||||
)}
|
||||
>
|
||||
{project.collections === null ? (
|
||||
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>Collections aren't available yet for live projects — this needs a billing module in be-crm.</p></div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>No payments collected yet for {project.name}.</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tm-search proj-cd-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad-0 proj-table proj-cd-table">
|
||||
<div className="proj-row proj-head" style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
|
||||
<span>Name / Project</span><span>Reference ID</span><span>Date</span><span>Type</span><span>Status</span><span className="proj-c-num">Amount</span>
|
||||
</div>
|
||||
{filtered.map((r) => (
|
||||
<div className="proj-row" key={r.id} style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
|
||||
<div className="proj-lead">
|
||||
<div className="proj-lead-name">{r.name}</div>
|
||||
<div className="proj-lead-sub">{r.address}</div>
|
||||
</div>
|
||||
<span className="proj-text">{r.referenceId}</span>
|
||||
<span className="proj-text">{r.date}</span>
|
||||
<span className="proj-text">{r.type}</span>
|
||||
<Pill tone={COLLECTION_STATUS_TONE[r.status]}>{COLLECTION_STATUS_LABEL[r.status].toUpperCase()}</Pill>
|
||||
<span className="proj-c-num">{moneyExact(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="proj-foot">
|
||||
<span>Showing {filtered.length} record{filtered.length === 1 ? "" : "s"}</span>
|
||||
<b>Net Total: {moneyExact(netTotal)}</b>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,7 @@ const NAV_PERMISSION: Record<string, string | undefined> = {
|
||||
leads: "leads.manage",
|
||||
verify: "leads.manage",
|
||||
pipeline: "pipeline.manage",
|
||||
projects: "pipeline.manage",
|
||||
estimates: "estimates.create",
|
||||
procanvas: "estimates.create",
|
||||
dispatch: "dispatch.manage",
|
||||
|
||||
@@ -222,9 +222,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,
|
||||
@@ -251,7 +251,10 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||
<div className="ds-modal-head-r">
|
||||
{headerExtra}
|
||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-modal-body">{children}</div>
|
||||
{footer && <div className="ds-modal-foot">{footer}</div>}
|
||||
|
||||
Reference in New Issue
Block a user