Files
mrp/client/src/modules/dashboard/DashboardPage.tsx

445 lines
22 KiB
TypeScript
Raw Normal View History

2026-03-15 00:14:54 -05:00
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
2026-03-14 14:44:40 -05:00
import { Link } from "react-router-dom";
2026-03-15 00:14:54 -05:00
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);
}
2026-03-14 14:44:40 -05:00
export function DashboardPage() {
2026-03-15 00:14:54 -05:00
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;
2026-03-15 00:09:16 -05:00
const metricCards = [
2026-03-15 00:14:54 -05:00
{
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",
},
2026-03-15 00:09:16 -05:00
];
const modulePanels = [
{
2026-03-15 00:14:54 -05:00
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.",
2026-03-15 00:09:16 -05:00
metrics: [
2026-03-15 00:14:54 -05:00
{ 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" },
2026-03-15 00:09:16 -05:00
],
links: [
2026-03-15 00:14:54 -05:00
{ label: "Open customers", to: "/crm/customers" },
{ label: "Open vendors", to: "/crm/vendors" },
2026-03-15 00:09:16 -05:00
],
},
{
2026-03-15 00:14:54 -05:00
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.",
2026-03-15 00:09:16 -05:00
metrics: [
2026-03-15 00:14:54 -05:00
{ 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" },
2026-03-15 00:09:16 -05:00
],
links: [
{ label: "Open inventory", to: "/inventory/items" },
2026-03-15 00:14:54 -05:00
{ label: "Open warehouses", to: "/inventory/warehouses" },
2026-03-15 00:09:16 -05:00
],
},
{
2026-03-15 00:14:54 -05:00
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.",
2026-03-15 00:09:16 -05:00
metrics: [
2026-03-15 00:14:54 -05:00
{ 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" },
2026-03-15 00:09:16 -05:00
],
links: [
2026-03-15 00:14:54 -05:00
{ label: "Open quotes", to: "/sales/quotes" },
{ label: "Open sales orders", to: "/sales/orders" },
],
},
{
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: "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 shipments", to: "/shipping/shipments" },
{ label: "Open packing flow", to: "/sales/orders" },
2026-03-15 00:09:16 -05:00
],
},
];
const futureModules = [
2026-03-15 00:14:54 -05:00
"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",
2026-03-15 00:09:16 -05:00
];
2026-03-14 14:44:40 -05:00
return (
2026-03-15 00:09:16 -05:00
<div className="space-y-4">
<section className="overflow-hidden rounded-[30px] border border-line/70 bg-surface/90 shadow-panel backdrop-blur">
<div className="grid gap-0 xl:grid-cols-[1.35fr_0.65fr]">
<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="relative">
2026-03-15 00:14:54 -05:00
<div className="flex flex-wrap items-center justify-between gap-3">
<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>
2026-03-15 00:09:16 -05:00
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
2026-03-15 00:14:54 -05:00
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.
2026-03-15 00:09:16 -05:00
</p>
2026-03-15 00:14:54 -05:00
<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">
2026-03-15 00:09:16 -05:00
<Link className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" to="/sales/orders">
Open sales orders
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/shipping/shipments">
Open shipments
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/inventory/items">
Open inventory
</Link>
</div>
2026-03-15 00:14:54 -05:00
{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}
2026-03-15 00:09:16 -05:00
</div>
</div>
<div className="border-l border-line/70 bg-page/40 p-5 2xl:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Upgrade Path</p>
<div className="mt-4 space-y-3">
{futureModules.map((item) => (
<div key={item} className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2 text-sm text-text">
{item}
</div>
))}
</div>
</div>
2026-03-14 14:44:40 -05:00
</div>
</section>
2026-03-15 00:09:16 -05:00
<section className="grid gap-3 xl:grid-cols-4">
{metricCards.map((card) => (
<article key={card.label} className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{card.label}</p>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="text-xl font-extrabold text-text">{card.value}</div>
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${card.tone}`}>Live</span>
2026-03-14 14:44:40 -05:00
</div>
2026-03-15 00:09:16 -05:00
<p className="mt-2 text-sm text-muted">{card.detail}</p>
</article>
))}
</section>
2026-03-15 00:14:54 -05:00
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
2026-03-15 00:09:16 -05:00
{modulePanels.map((panel) => (
<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>
<h4 className="mt-2 text-lg font-bold text-text">{panel.title}</h4>
<p className="mt-3 text-sm leading-6 text-muted">{panel.summary}</p>
<div className="mt-5 grid gap-2">
{panel.metrics.map((metric) => (
<div key={metric.label} 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">{metric.label}</span>
<span className="font-semibold text-text">{metric.value}</span>
</div>
))}
</div>
<div className="mt-5 flex flex-wrap gap-2">
{panel.links.map((link) => (
<Link key={link.to} to={link.to} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
{link.label}
</Link>
))}
</div>
</article>
))}
2026-03-14 14:44:40 -05:00
</section>
2026-03-15 00:14:54 -05:00
<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>
2026-03-14 14:44:40 -05:00
</div>
);
}