From f66001e5143127feebfb72d85f4f503d0ca2c40a Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 00:14:54 -0500 Subject: [PATCH] live dashboard --- .../src/modules/dashboard/DashboardPage.tsx | 386 ++++++++++++++++-- 1 file changed, 346 insertions(+), 40 deletions(-) diff --git a/client/src/modules/dashboard/DashboardPage.tsx b/client/src/modules/dashboard/DashboardPage.tsx index 06be2a7..04409dd 100644 --- a/client/src/modules/dashboard/DashboardPage.tsx +++ b/client/src/modules/dashboard/DashboardPage.tsx @@ -1,22 +1,259 @@ +import { permissions } from "@mrp/shared"; +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; +import { useAuth } from "../../auth/AuthProvider"; +import { ApiError, api } from "../../lib/api"; + +interface DashboardSnapshot { + customers: Awaited> | null; + vendors: Awaited> | null; + items: Awaited> | null; + warehouses: Awaited> | null; + quotes: Awaited> | null; + orders: Awaited> | null; + shipments: Awaited> | null; + refreshedAt: string; +} + +function hasPermission(userPermissions: string[] | undefined, permission: string) { + return Boolean(userPermissions?.includes(permission)); +} + +function formatCurrency(value: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(value); +} + +function formatDateTime(value: string | null) { + if (!value) { + return "No recent activity"; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function sumNumber(values: number[]) { + return values.reduce((total, value) => total + value, 0); +} + export function DashboardPage() { + const { token, user } = useAuth(); + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token || !user) { + setSnapshot(null); + setIsLoading(false); + return; + } + + const authToken = token; + let isMounted = true; + setIsLoading(true); + setError(null); + + const canReadCrm = hasPermission(user.permissions, permissions.crmRead); + const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead); + const canReadSales = hasPermission(user.permissions, permissions.salesRead); + const canReadShipping = hasPermission(user.permissions, permissions.shippingRead); + + async function loadSnapshot() { + const results = await Promise.allSettled([ + canReadCrm ? api.getCustomers(authToken) : Promise.resolve(null), + canReadCrm ? api.getVendors(authToken) : Promise.resolve(null), + canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null), + canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null), + canReadSales ? api.getQuotes(authToken) : Promise.resolve(null), + canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null), + canReadShipping ? api.getShipments(authToken) : Promise.resolve(null), + ]); + + if (!isMounted) { + return; + } + + const firstRejected = results.find((result) => result.status === "rejected"); + if (firstRejected?.status === "rejected") { + const reason = firstRejected.reason; + setError(reason instanceof ApiError ? reason.message : "Unable to load dashboard data."); + } + + setSnapshot({ + customers: results[0].status === "fulfilled" ? results[0].value : null, + vendors: results[1].status === "fulfilled" ? results[1].value : null, + items: results[2].status === "fulfilled" ? results[2].value : null, + warehouses: results[3].status === "fulfilled" ? results[3].value : null, + quotes: results[4].status === "fulfilled" ? results[4].value : null, + orders: results[5].status === "fulfilled" ? results[5].value : null, + shipments: results[6].status === "fulfilled" ? results[6].value : null, + refreshedAt: new Date().toISOString(), + }); + setIsLoading(false); + } + + loadSnapshot().catch((loadError) => { + if (!isMounted) { + return; + } + + setError(loadError instanceof ApiError ? loadError.message : "Unable to load dashboard data."); + setSnapshot(null); + setIsLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [token, user]); + + const customers = snapshot?.customers ?? []; + const vendors = snapshot?.vendors ?? []; + const items = snapshot?.items ?? []; + const warehouses = snapshot?.warehouses ?? []; + const quotes = snapshot?.quotes ?? []; + const orders = snapshot?.orders ?? []; + const shipments = snapshot?.shipments ?? []; + + const accessibleModules = [ + snapshot?.customers !== null || snapshot?.vendors !== null, + snapshot?.items !== null || snapshot?.warehouses !== null, + snapshot?.quotes !== null || snapshot?.orders !== null, + snapshot?.shipments !== null, + ].filter(Boolean).length; + + const customerCount = customers.length; + const resellerCount = customers.filter((customer) => customer.isReseller).length; + const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length; + const strategicCustomerCount = customers.filter((customer) => customer.strategicAccount).length; + const vendorCount = vendors.length; + + const itemCount = items.length; + const assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length; + const activeItemCount = items.filter((item) => item.status === "ACTIVE").length; + const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length; + const warehouseCount = warehouses.length; + const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount)); + + const quoteCount = quotes.length; + const orderCount = orders.length; + const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length; + const issuedOrderCount = orders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length; + const quoteValue = sumNumber(quotes.map((quote) => quote.total)); + const orderValue = sumNumber(orders.map((order) => order.total)); + + const shipmentCount = shipments.length; + const activeShipmentCount = shipments.filter((shipment) => shipment.status !== "DELIVERED").length; + const inTransitCount = shipments.filter((shipment) => shipment.status === "SHIPPED").length; + const deliveredCount = shipments.filter((shipment) => shipment.status === "DELIVERED").length; + + const lastActivityAt = [ + ...customers.map((customer) => customer.updatedAt), + ...vendors.map((vendor) => vendor.updatedAt), + ...items.map((item) => item.updatedAt), + ...warehouses.map((warehouse) => warehouse.updatedAt), + ...quotes.map((quote) => quote.updatedAt), + ...orders.map((order) => order.updatedAt), + ...shipments.map((shipment) => shipment.updatedAt), + ] + .sort() + .at(-1) ?? null; + const metricCards = [ - { label: "CRM Scope", value: "Complete", detail: "Customers, vendors, hierarchy, contacts, attachments", tone: "border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300" }, - { label: "Inventory", value: "Live", detail: "Items, BOMs, warehouses, stock, transactions", tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300" }, - { label: "Sales", value: "Active", detail: "Quotes, orders, totals, reseller pricing defaults", tone: "border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300" }, - { label: "Shipping", value: "Online", detail: "Shipments, status flow, packing-slip PDFs", tone: "border-brand/30 bg-brand/10 text-brand" }, + { + label: "CRM Accounts", + value: snapshot?.customers !== null ? `${customerCount}` : "No access", + detail: + snapshot?.customers !== null + ? `${vendorCount} vendors, ${resellerCount} resellers, ${activeCustomerCount} active` + : "CRM metrics are permission-gated.", + tone: "border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", + }, + { + label: "Inventory Footprint", + value: snapshot?.items !== null ? `${itemCount}` : "No access", + detail: + snapshot?.items !== null + ? `${assemblyCount} buildable items across ${warehouseCount} warehouses` + : "Inventory metrics are permission-gated.", + tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300", + }, + { + label: "Commercial Value", + value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access", + detail: + snapshot?.quotes !== null || snapshot?.orders !== null + ? `${quoteCount} quotes and ${orderCount} orders in the pipeline` + : "Sales metrics are permission-gated.", + tone: "border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300", + }, + { + label: "Shipping Queue", + value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access", + detail: + snapshot?.shipments !== null + ? `${inTransitCount} in transit, ${deliveredCount} delivered` + : "Shipping metrics are permission-gated.", + tone: "border-brand/30 bg-brand/10 text-brand", + }, ]; const modulePanels = [ { - title: "Commercial", - eyebrow: "Revenue Flow", - summary: "Quotes and sales orders now support item pricing, discount, tax, freight, and conversion workflows.", + title: "CRM", + eyebrow: "Account Health", + summary: + snapshot?.customers !== null + ? "Live account counts, reseller coverage, and strategic-account concentration from the current CRM records." + : "CRM read permission is required to surface customer and vendor metrics here.", metrics: [ - { label: "Quote -> SO", value: "Enabled" }, - { label: "Totals Logic", value: "Live" }, - { label: "Customer Lookup", value: "Searchable" }, + { label: "Customers", value: snapshot?.customers !== null ? `${customerCount}` : "No access" }, + { label: "Strategic", value: snapshot?.customers !== null ? `${strategicCustomerCount}` : "No access" }, + { label: "Vendors", value: snapshot?.vendors !== null ? `${vendorCount}` : "No access" }, + ], + links: [ + { label: "Open customers", to: "/crm/customers" }, + { label: "Open vendors", to: "/crm/vendors" }, + ], + }, + { + title: "Inventory", + eyebrow: "Master + Stock", + summary: + snapshot?.items !== null + ? "Item master, BOM-capable parts, and warehouse footprint are now feeding the dashboard directly." + : "Inventory read permission is required to surface item and warehouse metrics here.", + metrics: [ + { label: "Active items", value: snapshot?.items !== null ? `${activeItemCount}` : "No access" }, + { label: "Assemblies", value: snapshot?.items !== null ? `${assemblyCount}` : "No access" }, + { label: "Locations", value: snapshot?.warehouses !== null ? `${locationCount}` : "No access" }, + ], + links: [ + { label: "Open inventory", to: "/inventory/items" }, + { label: "Open warehouses", to: "/inventory/warehouses" }, + ], + }, + { + title: "Sales", + eyebrow: "Revenue Flow", + summary: + snapshot?.quotes !== null || snapshot?.orders !== null + ? "Quotes and sales orders now contribute real commercial value, open-document counts, and pipeline visibility." + : "Sales read permission is required to surface commercial metrics here.", + metrics: [ + { label: "Quote value", value: snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access" }, + { label: "Order value", value: snapshot?.orders !== null ? formatCurrency(orderValue) : "No access" }, + { label: "Draft quotes", value: snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access" }, ], links: [ { label: "Open quotes", to: "/sales/quotes" }, @@ -24,40 +261,29 @@ export function DashboardPage() { ], }, { - title: "Operations", - eyebrow: "Stock + Ship", - summary: "Inventory and shipping now share a usable operational path from item master through shipment paperwork.", + title: "Shipping", + eyebrow: "Execution Queue", + summary: + snapshot?.shipments !== null + ? "Shipment records, in-transit volume, and completed deliveries are now visible from the landing page." + : "Shipping read permission is required to surface shipment metrics here.", metrics: [ - { label: "On-Hand", value: "Tracked" }, - { label: "Shipments", value: "Linked" }, - { label: "Packing Slips", value: "Ready" }, + { label: "Open shipments", value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access" }, + { label: "In transit", value: snapshot?.shipments !== null ? `${inTransitCount}` : "No access" }, + { label: "Delivered", value: snapshot?.shipments !== null ? `${deliveredCount}` : "No access" }, ], links: [ - { label: "Open inventory", to: "/inventory/items" }, { label: "Open shipments", to: "/shipping/shipments" }, - ], - }, - { - title: "Planning", - eyebrow: "Next Layer", - summary: "The dashboard is now intended as a modular command surface for future purchasing, manufacturing, and execution metrics.", - metrics: [ - { label: "PO Module", value: "Next" }, - { label: "Gantt", value: "Preview" }, - { label: "Audit", value: "Pending" }, - ], - links: [ - { label: "Open gantt", to: "/planning/gantt" }, - { label: "Company settings", to: "/settings/company" }, + { label: "Open packing flow", to: "/sales/orders" }, ], }, ]; const futureModules = [ - "Purchasing queue and supplier receipts", - "Shipment labels and bills of lading", - "Manufacturing schedule and bottleneck metrics", - "Audit and system-health diagnostics", + "Purchase-order queue and supplier receipts", + "Stock transfers, allocations, and cycle counts", + "Shipping labels, bills of lading, and delivery exceptions", + "Manufacturing schedule, work orders, and bottleneck metrics", ]; return ( @@ -67,12 +293,35 @@ export function DashboardPage() {
-

Dashboard

-

Operational command surface for metrics, movement, and next actions.

+
+
+

Dashboard

+

Operational command surface for metrics, movement, and next actions.

+
+
+

Last Refresh

+

{snapshot ? formatDateTime(snapshot.refreshedAt) : "Waiting"}

+
+

- This dashboard is now the primary landing surface for the platform. It is intended to stay modular as purchasing, manufacturing, shipping, and audit metrics are added in future slices. + This landing page now reads directly from live CRM, inventory, sales, and shipping data. It is intentionally modular so future purchasing, + manufacturing, and audit slices can slot into the same command surface without a redesign.

-
+
+
+

Modules Live

+

{accessibleModules}

+
+
+

Recent Activity

+

{formatDateTime(lastActivityAt)}

+
+
+

Loading State

+

{isLoading ? "Refreshing data" : "Live snapshot loaded"}

+
+
+
Open sales orders @@ -83,6 +332,7 @@ export function DashboardPage() { Open inventory
+ {error ?
{error}
: null}
@@ -109,7 +359,7 @@ export function DashboardPage() { ))} -
+
{modulePanels.map((panel) => (

{panel.eyebrow}

@@ -133,6 +383,62 @@ export function DashboardPage() {
))}
+
+
+

Inventory Watch

+

Master data pressure points

+
+
+ Obsolete items + {snapshot?.items !== null ? `${obsoleteItemCount}` : "No access"} +
+
+ Warehouse count + {snapshot?.warehouses !== null ? `${warehouseCount}` : "No access"} +
+
+ Stock locations + {snapshot?.warehouses !== null ? `${locationCount}` : "No access"} +
+
+
+
+

Sales Watch

+

Commercial flow snapshot

+
+
+ Issued orders + {snapshot?.orders !== null ? `${issuedOrderCount}` : "No access"} +
+
+ Draft quotes + {snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access"} +
+
+ Order backlog + {snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"} +
+
+
+
+

Shipping Watch

+

Execution and delivery status

+
+
+ Total shipments + {snapshot?.shipments !== null ? `${shipmentCount}` : "No access"} +
+
+ Open queue + {snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access"} +
+
+ Delivered + {snapshot?.shipments !== null ? `${deliveredCount}` : "No access"} +
+
+
+
); }