5 Commits

Author SHA1 Message Date
abe-kap 948b38a016 Merge remote-tracking branch 'origin/goutamnextflow' into feat/projects
# Conflicts:
#	src/app/dashboard/dashboard.css
#	src/components/dashboard/dashboard.tsx
2026-07-22 17:26:45 -04:00
abe-kap d44cacb778 feat(projects): add the Projects CRM module 2026-07-22 17:19:23 -04:00
maaz519 e04f1e6086 chore(crm): pull SDK 0.1.2 (uploading-progress chip) 2026-07-23 01:04:52 +05:30
maaz519 ae27830893 fix(inbox/messenger): directory limit 100 (was 400ing) + attachment download
- crm.messenger.directory limit 200 -> 100 (be-crm caps at 100, was 400 Bad
  Request) in both the inbox + messaging adapters.
- CrmInboxAdapter.downloadAttachment via crm.media.presignDownload, so mail
  attachments are downloadable. Pull SDK 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:53:08 +05:30
maaz519 84cb5c66b5 Merge pull request 'Feat/adopt messaging sdk' (#31) from feat/adopt-messaging-sdk into goutamnextflow
Reviewed-on: #31
2026-07-22 19:06:48 +00:00
11 changed files with 670 additions and 15 deletions
+4 -4
View File
@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6", "@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4", "@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.0", "@insignia/iios-messaging-ui": "^0.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "16.2.9", "next": "16.2.9",
@@ -1028,9 +1028,9 @@
} }
}, },
"node_modules/@insignia/iios-messaging-ui": { "node_modules/@insignia/iios-messaging-ui": {
"version": "0.1.0", "version": "0.1.2",
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.0/iios-messaging-ui-0.1.0.tgz", "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-iGOh/lwJJm+QG2u5YP1Kj4gZyShRWO1o7/dxnl+DE1m/WbE0PDO+baTJ7NFhremubSB7Wc/KSLhf9yLdLbivAQ==", "integrity": "sha512-YsoTM9vmjQ+dk6Hch5nXuqMFAdybd3luAZepfaliOw727ylc8FPAtwTMMcIWQ3WOQPa2eKeGLiEpo61QCO0GrA==",
"peerDependencies": { "peerDependencies": {
"@insignia/iios-kernel-client": "*", "@insignia/iios-kernel-client": "*",
"react": ">=18", "react": ">=18",
+1 -1
View File
@@ -11,7 +11,7 @@
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6", "@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4", "@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.0", "@insignia/iios-messaging-ui": "^0.1.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "16.2.9", "next": "16.2.9",
+36 -2
View File
@@ -447,8 +447,10 @@
.dash-root .ds-modal.size-sm { max-width: 400px; } .dash-root .ds-modal.size-sm { max-width: 400px; }
.dash-root .ds-modal.size-md { max-width: 540px; } .dash-root .ds-modal.size-md { max-width: 540px; }
.dash-root .ds-modal.size-lg { max-width: 760px; } .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 { 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-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-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 h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; } .dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
@@ -922,7 +924,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 { 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 svg { color: #fff; }
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); } .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 { 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: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; } .dash-root .tm-search .ds-input { flex: 1; }
@@ -1154,8 +1156,40 @@
.dash-root .ai-caps { grid-template-columns: 1fr; } .dash-root .ai-caps { grid-template-columns: 1fr; }
.dash-root .ai-bubble { max-width: 86%; } .dash-root .ai-bubble { max-width: 86%; }
.dash-root .ai-view { height: calc(100vh - 150px); } .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 ---- */ /* ---- Org Settings → Integrations ---- */
.dash-root .settings-section { margin-top: 8px; } .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; } .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; }
+2
View File
@@ -18,6 +18,7 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk"; import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk"; import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings"; import { Settings } from "./settings";
import { Projects } from "./projects";
import "../../app/dashboard/dashboard.css"; import "../../app/dashboard/dashboard.css";
export function Dashboard() { export function Dashboard() {
@@ -52,6 +53,7 @@ export function Dashboard() {
: active === "inbox" ? <InboxSdk /> : active === "inbox" ? <InboxSdk />
: active === "settings" ? <Settings /> : active === "settings" ? <Settings />
: active === "team" ? <TeamManagement /> : active === "team" ? <TeamManagement />
: active === "projects" ? <Projects />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />} : <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider> </ToastProvider>
</div> </div>
+148
View File
@@ -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`,
};
});
+258
View File
@@ -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&apos;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&apos;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>
);
}
+1
View File
@@ -86,6 +86,7 @@ const NAV_PERMISSION: Record<string, string | undefined> = {
leads: "leads.manage", leads: "leads.manage",
verify: "leads.manage", verify: "leads.manage",
pipeline: "pipeline.manage", pipeline: "pipeline.manage",
projects: "pipeline.manage",
estimates: "estimates.create", estimates: "estimates.create",
procanvas: "estimates.create", procanvas: "estimates.create",
dispatch: "dispatch.manage", dispatch: "dispatch.manage",
+9 -6
View File
@@ -24,7 +24,7 @@ import {
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter, LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays, Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal, Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, type LucideIcon, UsersRound, Download, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
@@ -49,7 +49,7 @@ const ICONS: Record<string, LucideIcon> = {
storm: CloudLightning, territory: MapIcon, procanvas: PenTool, storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy, estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles, 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 }) { 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 */ /* Modal */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: { export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string; open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg"; children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
}) { }) {
const titleId = useId(); const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport, // 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 && <p>{subtitle}</p>} {subtitle && <p>{subtitle}</p>}
</div> </div>
</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>
<div className="ds-modal-body">{children}</div> <div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>} {footer && <div className="ds-modal-foot">{footer}</div>}
+9 -1
View File
@@ -93,8 +93,16 @@ export class CrmInboxAdapter implements InboxAdapter {
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name }; return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
} }
async downloadAttachment(attachment: MailAttachment): Promise<string> {
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", {
contentRef: attachment.contentRef,
...(attachment.mimeType ? { mime: attachment.mimeType } : {}),
});
return url;
}
async directory(): Promise<MailPerson[]> { async directory(): Promise<MailPerson[]> {
const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 200 }); const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })); return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
} }
+1 -1
View File
@@ -221,7 +221,7 @@ export class CrmMessagingAdapter implements MessagingAdapter {
/** The org directory — people you can start a DM/group with. Drives the "New message" picker. */ /** The org directory — people you can start a DM/group with. Drives the "New message" picker. */
async directory(): Promise<Person[]> { async directory(): Promise<Person[]> {
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 200 }); const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })); return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
} }
+201
View File
@@ -0,0 +1,201 @@
"use client";
// Projects data layer. Serves EITHER the local mock (when the Shell isn't
// configured — the polished demo keeps working) OR the live be-crm data door
// (crm.project.*), behind one interface so the component is mode-agnostic.
// Mirrors team-api.ts.
//
// Live contract (be-crm):
// query crm.project.list { perPage } -> { items: ProjectDTO[], meta }
// cmd crm.project.create { name, status?, value?, address?, dueDate?, ... }
// cmd crm.project.setStatus { id, status }
// cmd crm.project.remove { id }
//
// be-crm's Project model is generic (name/status/value/owner/address/dates) —
// it has no stage, job type, progress or health score. Those are mock-only
// fields (undefined in live mode); the Projects screen renders a leaner
// column set when `live` is true.
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
collectionsFor, constructionLeads, pipelineLeads,
type CollectionRecord, type ConstructionStatus,
} from "@/components/dashboard/projects-data";
export type UiCollectionRecord = CollectionRecord;
/* ---- UI-facing shapes ---------------------------------------------------- */
// "lead" / "archived" round out the mock's four (active/complete/stuck/followup)
// so every be-crm ProjectStatus has somewhere to map to.
export type UiProjectStatus = ConstructionStatus | "lead" | "archived";
export interface UiProject {
id: string;
name: string;
address: string | null;
status: UiProjectStatus;
stage: string | null; // mock only
jobType: string | null; // mock only
agent: string;
progress: number | null; // mock only
health: number | null; // mock only
value: number | null; // dollars
dueDate: string | null;
createdAgo: string | null; // mock pipeline leads only
collections: UiCollectionRecord[] | null; // mock only — no billing backend yet
isLead: boolean; // true → "Pipeline Leads" tab
}
export interface NewProjectInput {
name: string;
status: UiProjectStatus;
value?: number; // dollars
}
export interface ProjectsData {
live: boolean;
loading: boolean;
error: string | null;
projects: UiProject[];
create: (p: NewProjectInput) => Promise<void>;
setStatus: (id: string, status: UiProjectStatus) => Promise<void>;
remove: (id: string) => Promise<void>;
refetch: () => void;
}
export const STATUS_LABEL: Record<UiProjectStatus, string> = {
lead: "Lead", active: "Active", complete: "Complete", stuck: "Stuck", followup: "Follow-up", archived: "Archived",
};
export const STATUS_TONE: Record<UiProjectStatus, string> = {
lead: "purple", active: "green", complete: "blue", stuck: "red", followup: "orange", archived: "muted",
};
// Quick-filter chips only cover the four "in-flight job" statuses (mirrors the design).
export const QUICK_FILTERS: UiProjectStatus[] = ["active", "complete", "stuck", "followup"];
/* ---- mock → UI ------------------------------------------------------------ */
function mockConstruction(c: (typeof constructionLeads)[number], index: number): UiProject {
return {
id: c.id, name: c.name, address: c.address, status: c.status, stage: c.stage, jobType: c.jobType,
agent: c.agent, progress: c.progress, health: c.health, value: c.value, dueDate: null, createdAgo: null,
collections: collectionsFor(c, index), isLead: false,
};
}
function mockPipeline(p: (typeof pipelineLeads)[number]): UiProject {
return {
id: p.id, name: p.name, address: p.address, status: "lead", stage: p.stage, jobType: p.jobType,
agent: p.agent, progress: null, health: null, value: null, dueDate: null, createdAgo: p.createdAgo,
collections: [], isLead: true,
};
}
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
let mockSeq = 1;
function useMockProjects(): ProjectsData {
const [projects, setProjects] = useState<UiProject[]>(() => [
...constructionLeads.map(mockConstruction),
...pipelineLeads.map(mockPipeline),
]);
const create = useCallback(async (p: NewProjectInput) => {
const isLead = p.status === "lead";
const next: UiProject = {
id: `new_${mockSeq++}`, name: p.name, address: null, status: p.status,
stage: isLead ? "New Inquiry" : "New Lead", jobType: null, agent: "Unassigned",
progress: isLead ? null : 0, health: isLead ? null : 70, value: isLead ? null : (p.value ?? null),
dueDate: null, createdAgo: isLead ? "Just now" : null, collections: [], isLead,
};
setProjects((l) => [next, ...l]);
}, []);
const setStatus = useCallback(async (id: string, status: UiProjectStatus) => {
setProjects((l) => l.map((p) => (p.id === id ? { ...p, status, isLead: status === "lead" } : p)));
}, []);
const remove = useCallback(async (id: string) => { setProjects((l) => l.filter((p) => p.id !== id)); }, []);
return { live: false, loading: false, error: null, projects, create, setStatus, remove, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
type BackendStatus = "lead" | "active" | "on_hold" | "won" | "lost" | "archived";
const BACKEND_TO_UI: Record<BackendStatus, UiProjectStatus> = {
lead: "lead", active: "active", on_hold: "stuck", won: "complete", lost: "followup", archived: "archived",
};
const UI_TO_BACKEND: Record<UiProjectStatus, BackendStatus> = {
lead: "lead", active: "active", stuck: "on_hold", complete: "won", followup: "lost", archived: "archived",
};
interface ProjectAddressDTO { line1?: string | null; city?: string | null; state?: string | null; postalCode?: string | null }
interface ProjectDTO {
id: string; name: string; status: BackendStatus; ownerPrincipalId: string | null;
value: number | null; address: ProjectAddressDTO | null; dueDate: string | null;
}
interface MemberRef { principalId: string; displayName: string | null; jobTitle: string | null }
const fmtAddress = (a: ProjectAddressDTO | null): string | null => {
if (!a) return null;
const cityState = [a.city, a.state].filter(Boolean).join(" ");
const parts = [a.line1, [cityState, a.postalCode].filter(Boolean).join(" ")].filter(Boolean);
return parts.length ? parts.join(", ") : null;
};
function useLiveProjects(): ProjectsData {
const { sdk } = useAppShell();
const listQ = useQuery<{ items: ProjectDTO[] }>("crm.project.list", { perPage: 100 });
// Resolve ownerPrincipalId → a real name for the "Owner" column (crm.project.list
// only returns the raw id).
const membersQ = useQuery<{ items: MemberRef[] }>("crm.team.member.search", { perPage: 100 });
const refetch = useCallback(() => { listQ.refetch(); membersQ.refetch(); }, [listQ, membersQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const nameByPrincipal = useMemo(() => {
const m = new Map<string, string>();
for (const it of membersQ.data?.items ?? []) if (it.principalId) m.set(it.principalId, it.displayName || it.jobTitle || it.principalId);
return m;
}, [membersQ.data]);
const projects: UiProject[] = useMemo(() => (listQ.data?.items ?? []).map((d) => ({
id: d.id, name: d.name, address: fmtAddress(d.address),
status: BACKEND_TO_UI[d.status] ?? "active",
stage: null, jobType: null,
agent: d.ownerPrincipalId ? (nameByPrincipal.get(d.ownerPrincipalId) ?? "Unassigned") : "Unassigned",
progress: null, health: null,
value: d.value != null ? d.value / 100 : null, // minor units → dollars
dueDate: d.dueDate, createdAgo: null, collections: null, isLead: d.status === "lead",
})), [listQ.data, nameByPrincipal]);
return {
live: true,
loading: listQ.loading || membersQ.loading,
error: (listQ.error ?? membersQ.error)?.message ?? null,
projects,
create: (p) => cmd("crm.project.create", {
name: p.name, status: UI_TO_BACKEND[p.status],
...(p.value != null ? { value: Math.round(p.value * 100) } : {}),
}),
setStatus: (id, status) => cmd("crm.project.setStatus", { id, status: UI_TO_BACKEND[status] }),
remove: (id) => cmd("crm.project.remove", { id }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useProjectsData(): ProjectsData {
return SHELL ? useLiveProjects() : useMockProjects();
}