live dashboard

This commit is contained in:
2026-03-15 00:14:54 -05:00
parent 4a2be400c5
commit f66001e514

View File

@@ -1,22 +1,259 @@
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api";
interface DashboardSnapshot {
customers: Awaited<ReturnType<typeof api.getCustomers>> | null;
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | 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() { export function DashboardPage() {
const { token, user } = useAuth();
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 = [ 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: "CRM Accounts",
{ 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" }, value: snapshot?.customers !== null ? `${customerCount}` : "No access",
{ label: "Shipping", value: "Online", detail: "Shipments, status flow, packing-slip PDFs", tone: "border-brand/30 bg-brand/10 text-brand" }, 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 = [ const modulePanels = [
{ {
title: "Commercial", title: "CRM",
eyebrow: "Revenue Flow", eyebrow: "Account Health",
summary: "Quotes and sales orders now support item pricing, discount, tax, freight, and conversion workflows.", 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: [ metrics: [
{ label: "Quote -> SO", value: "Enabled" }, { label: "Customers", value: snapshot?.customers !== null ? `${customerCount}` : "No access" },
{ label: "Totals Logic", value: "Live" }, { label: "Strategic", value: snapshot?.customers !== null ? `${strategicCustomerCount}` : "No access" },
{ label: "Customer Lookup", value: "Searchable" }, { 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: [ links: [
{ label: "Open quotes", to: "/sales/quotes" }, { label: "Open quotes", to: "/sales/quotes" },
@@ -24,40 +261,29 @@ export function DashboardPage() {
], ],
}, },
{ {
title: "Operations", title: "Shipping",
eyebrow: "Stock + Ship", eyebrow: "Execution Queue",
summary: "Inventory and shipping now share a usable operational path from item master through shipment paperwork.", 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: [ metrics: [
{ label: "On-Hand", value: "Tracked" }, { label: "Open shipments", value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access" },
{ label: "Shipments", value: "Linked" }, { label: "In transit", value: snapshot?.shipments !== null ? `${inTransitCount}` : "No access" },
{ label: "Packing Slips", value: "Ready" }, { label: "Delivered", value: snapshot?.shipments !== null ? `${deliveredCount}` : "No access" },
], ],
links: [ links: [
{ label: "Open inventory", to: "/inventory/items" },
{ label: "Open shipments", to: "/shipping/shipments" }, { label: "Open shipments", to: "/shipping/shipments" },
], { label: "Open packing flow", to: "/sales/orders" },
},
{
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" },
], ],
}, },
]; ];
const futureModules = [ const futureModules = [
"Purchasing queue and supplier receipts", "Purchase-order queue and supplier receipts",
"Shipment labels and bills of lading", "Stock transfers, allocations, and cycle counts",
"Manufacturing schedule and bottleneck metrics", "Shipping labels, bills of lading, and delivery exceptions",
"Audit and system-health diagnostics", "Manufacturing schedule, work orders, and bottleneck metrics",
]; ];
return ( return (
@@ -67,12 +293,35 @@ export function DashboardPage() {
<div className="relative overflow-hidden p-5 2xl:p-6"> <div className="relative overflow-hidden p-5 2xl:p-6">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(24,90,219,0.16),_transparent_44%),linear-gradient(135deg,rgba(255,255,255,0.06),transparent)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(24,90,219,0.16),_transparent_44%),linear-gradient(135deg,rgba(255,255,255,0.06),transparent)]" />
<div className="relative"> <div className="relative">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Dashboard</p> <div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="mt-2 max-w-3xl text-2xl font-extrabold text-text">Operational command surface for metrics, movement, and next actions.</h3> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Dashboard</p>
<h3 className="mt-2 max-w-3xl text-2xl font-extrabold text-text">Operational command surface for metrics, movement, and next actions.</h3>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-right">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted">Last Refresh</p>
<p className="mt-1 text-sm font-semibold text-text">{snapshot ? formatDateTime(snapshot.refreshedAt) : "Waiting"}</p>
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted"> <p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
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.
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Modules Live</p>
<p className="mt-1 text-lg font-extrabold text-text">{accessibleModules}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Recent Activity</p>
<p className="mt-1 text-sm font-semibold text-text">{formatDateTime(lastActivityAt)}</p>
</div>
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-muted">Loading State</p>
<p className="mt-1 text-sm font-semibold text-text">{isLoading ? "Refreshing data" : "Live snapshot loaded"}</p>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<Link className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" to="/sales/orders"> <Link className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" to="/sales/orders">
Open sales orders Open sales orders
</Link> </Link>
@@ -83,6 +332,7 @@ export function DashboardPage() {
Open inventory Open inventory
</Link> </Link>
</div> </div>
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
</div> </div>
</div> </div>
<div className="border-l border-line/70 bg-page/40 p-5 2xl:p-6"> <div className="border-l border-line/70 bg-page/40 p-5 2xl:p-6">
@@ -109,7 +359,7 @@ export function DashboardPage() {
</article> </article>
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-3"> <section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
{modulePanels.map((panel) => ( {modulePanels.map((panel) => (
<article key={panel.title} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article key={panel.title} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{panel.eyebrow}</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">{panel.eyebrow}</p>
@@ -133,6 +383,62 @@ export function DashboardPage() {
</article> </article>
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-3">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Obsolete items</span>
<span className="font-semibold text-text">{snapshot?.items !== null ? `${obsoleteItemCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Warehouse count</span>
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${warehouseCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Stock locations</span>
<span className="font-semibold text-text">{snapshot?.warehouses !== null ? `${locationCount}` : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Sales Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Commercial flow snapshot</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Issued orders</span>
<span className="font-semibold text-text">{snapshot?.orders !== null ? `${issuedOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Draft quotes</span>
<span className="font-semibold text-text">{snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Order backlog</span>
<span className="font-semibold text-text">{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipping Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Execution and delivery status</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Total shipments</span>
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${shipmentCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Open queue</span>
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Delivered</span>
<span className="font-semibold text-text">{snapshot?.shipments !== null ? `${deliveredCount}` : "No access"}</span>
</div>
</div>
</article>
</section>
</div> </div>
); );
} }