Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c38ce811f | |||
| 0c2b7ef132 |
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||
"@insignia/iios-kernel-client": "^0.1.4",
|
||||
"@insignia/iios-messaging-ui": "^0.1.2",
|
||||
"@insignia/iios-messaging-ui": "^0.1.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
@@ -1028,9 +1028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@insignia/iios-messaging-ui": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.2/iios-messaging-ui-0.1.2.tgz",
|
||||
"integrity": "sha512-YsoTM9vmjQ+dk6Hch5nXuqMFAdybd3luAZepfaliOw727ylc8FPAtwTMMcIWQ3WOQPa2eKeGLiEpo61QCO0GrA==",
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.4/iios-messaging-ui-0.1.4.tgz",
|
||||
"integrity": "sha512-xgwVPY//NFyCrGxuLpLlrXn89q2SMgQoKTiW46CWdLx6rkaaxSesVEijGEoYt25rVPVF9+RcDPbwIBQ2GDtHQw==",
|
||||
"peerDependencies": {
|
||||
"@insignia/iios-kernel-client": "*",
|
||||
"react": ">=18",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@abe-kap/appshell-sdk": "^0.2.6",
|
||||
"@insignia/iios-kernel-client": "^0.1.4",
|
||||
"@insignia/iios-messaging-ui": "^0.1.2",
|
||||
"@insignia/iios-messaging-ui": "^0.1.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
|
||||
@@ -447,10 +447,8 @@
|
||||
.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; }
|
||||
@@ -924,7 +922,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; margin-bottom: 12px; }
|
||||
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.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; }
|
||||
@@ -1156,40 +1154,8 @@
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* ---- 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; }
|
||||
|
||||
|
||||
/* ---- Org Settings → Integrations ---- */
|
||||
.dash-root .settings-section { margin-top: 8px; }
|
||||
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
|
||||
|
||||
@@ -18,7 +18,6 @@ import { TeamManagement } from "./team-management";
|
||||
import { MessengerSdk } from "./messenger-sdk";
|
||||
import { InboxSdk } from "./inbox-sdk";
|
||||
import { Settings } from "./settings";
|
||||
import { Projects } from "./projects";
|
||||
import "../../app/dashboard/dashboard.css";
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -53,7 +52,6 @@ export function Dashboard() {
|
||||
: active === "inbox" ? <InboxSdk />
|
||||
: active === "settings" ? <Settings />
|
||||
: active === "team" ? <TeamManagement />
|
||||
: active === "projects" ? <Projects />
|
||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||
</ToastProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// ============================================================
|
||||
// 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`,
|
||||
};
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,6 @@ 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",
|
||||
|
||||
@@ -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, Download, type LucideIcon,
|
||||
UsersRound, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
@@ -49,7 +49,7 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
|
||||
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
|
||||
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
|
||||
team: UsersRound, dots: MoreHorizontal, download: Download,
|
||||
team: UsersRound, dots: MoreHorizontal,
|
||||
};
|
||||
|
||||
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", headerExtra }: {
|
||||
open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
|
||||
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
|
||||
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";
|
||||
}) {
|
||||
const titleId = useId();
|
||||
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
|
||||
@@ -246,10 +246,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ds-modal-head-r">
|
||||
{headerExtra}
|
||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||
</div>
|
||||
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||
</div>
|
||||
<div className="ds-modal-body">{children}</div>
|
||||
{footer && <div className="ds-modal-foot">{footer}</div>}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
||||
|
||||
import type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
@@ -32,7 +33,7 @@ interface ConversationDTO {
|
||||
threadId: string; subject: string | null; membership: Membership | null;
|
||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null }
|
||||
|
||||
const POLL_MS = 4000;
|
||||
const REACTION = "reaction";
|
||||
@@ -58,7 +59,7 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
if (socket) {
|
||||
socket.on("message", (m) => {
|
||||
this.ingestReactions(m);
|
||||
this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) });
|
||||
void this.toKernelMessage(m).then((message) => this.emit(m.threadId, { kind: "message", message }));
|
||||
});
|
||||
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
|
||||
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
|
||||
@@ -99,28 +100,43 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
if (this.socket) {
|
||||
const res = await this.socket.openThread(threadId); // joins so live events flow
|
||||
this.joined.add(threadId);
|
||||
return res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.fromKernel(m);
|
||||
});
|
||||
return Promise.all(
|
||||
res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.toKernelMessage(m);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||
return msgs.map((m) => this.fromDto(m));
|
||||
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
const att = opts?.attachment;
|
||||
if (this.socket) {
|
||||
const sendOpts = {
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||
...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}),
|
||||
};
|
||||
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||
return this.fromKernel(m);
|
||||
const msg = this.fromKernel(m);
|
||||
// Reuse the staged attachment (already carries a display URL from upload) for instant render.
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
|
||||
const msg = this.fromDto(m);
|
||||
this.polls.get(threadId)?.seen.add(msg.id);
|
||||
return msg;
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<Attachment> {
|
||||
const mime = file.type || "application/octet-stream";
|
||||
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
|
||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
const url = await this.downloadUrl(objectKey, mime);
|
||||
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
|
||||
}
|
||||
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||
@@ -265,6 +281,31 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
|
||||
}
|
||||
|
||||
// ── attachments ────────────────────────────────────────────────
|
||||
/** A short-lived signed URL to display/download a stored object. */
|
||||
private async downloadUrl(contentRef: string, mime?: string): Promise<string> {
|
||||
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) });
|
||||
return url;
|
||||
}
|
||||
|
||||
private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise<Attachment | undefined> {
|
||||
if (!a?.contentRef) return undefined;
|
||||
const url = await this.downloadUrl(a.contentRef, a.mimeType);
|
||||
return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes };
|
||||
}
|
||||
|
||||
private async toKernelMessage(m: KernelMessage): Promise<Message> {
|
||||
const base = this.fromKernel(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
private async toDtoMessage(m: MessageDTO): Promise<Message> {
|
||||
const base = this.fromDto(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
// ── reaction state ─────────────────────────────────────────────
|
||||
private ingestReactions(m: KernelMessage): void {
|
||||
for (const a of m.annotations ?? []) {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"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