571 lines
30 KiB
TypeScript
571 lines
30 KiB
TypeScript
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<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;
|
|
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
|
|
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
|
|
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
|
|
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
|
|
projects: Awaited<ReturnType<typeof api.getProjects>> | 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<DashboardSnapshot | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
|
|
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
|
|
|
|
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 canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
|
|
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
|
|
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
|
|
const canReadProjects = hasPermission(user.permissions, permissions.projectsRead);
|
|
|
|
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),
|
|
canReadManufacturing ? api.getWorkOrders(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),
|
|
canReadProjects ? api.getProjects(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,
|
|
workOrders: results[4].status === "fulfilled" ? results[4].value : null,
|
|
quotes: results[5].status === "fulfilled" ? results[5].value : null,
|
|
orders: results[6].status === "fulfilled" ? results[6].value : null,
|
|
shipments: results[7].status === "fulfilled" ? results[7].value : null,
|
|
projects: results[8].status === "fulfilled" ? results[8].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 workOrders = snapshot?.workOrders ?? [];
|
|
const quotes = snapshot?.quotes ?? [];
|
|
const orders = snapshot?.orders ?? [];
|
|
const shipments = snapshot?.shipments ?? [];
|
|
const projects = snapshot?.projects ?? [];
|
|
|
|
const accessibleModules = [
|
|
snapshot?.customers !== null || snapshot?.vendors !== null,
|
|
snapshot?.items !== null || snapshot?.warehouses !== null,
|
|
snapshot?.workOrders !== null,
|
|
snapshot?.quotes !== null || snapshot?.orders !== null,
|
|
snapshot?.shipments !== null,
|
|
snapshot?.projects !== 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 workOrderCount = workOrders.length;
|
|
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
|
|
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
|
|
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
|
|
|
|
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 projectCount = projects.length;
|
|
const activeProjectCount = projects.filter((project) => project.status === "ACTIVE").length;
|
|
const atRiskProjectCount = projects.filter((project) => project.status === "AT_RISK").length;
|
|
const overdueProjectCount = projects.filter((project) => {
|
|
if (!project.dueDate || project.status === "COMPLETE") {
|
|
return false;
|
|
}
|
|
|
|
return new Date(project.dueDate).getTime() < Date.now();
|
|
}).length;
|
|
|
|
const lastActivityAt = [
|
|
...customers.map((customer) => customer.updatedAt),
|
|
...vendors.map((vendor) => vendor.updatedAt),
|
|
...items.map((item) => item.updatedAt),
|
|
...warehouses.map((warehouse) => warehouse.updatedAt),
|
|
...workOrders.map((workOrder) => workOrder.updatedAt),
|
|
...quotes.map((quote) => quote.updatedAt),
|
|
...orders.map((order) => order.updatedAt),
|
|
...shipments.map((shipment) => shipment.updatedAt),
|
|
...projects.map((project) => project.updatedAt),
|
|
]
|
|
.sort()
|
|
.at(-1) ?? null;
|
|
|
|
const metricCards = [
|
|
{
|
|
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: "Manufacturing Load",
|
|
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
|
|
detail:
|
|
snapshot?.workOrders !== null
|
|
? `${releasedWorkOrderCount} released and ${overdueWorkOrderCount} overdue`
|
|
: "Manufacturing metrics are permission-gated.",
|
|
tone: "border-indigo-400/30 bg-indigo-500/12 text-indigo-700 dark:text-indigo-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",
|
|
},
|
|
{
|
|
label: "Project Load",
|
|
value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access",
|
|
detail:
|
|
snapshot?.projects !== null
|
|
? `${atRiskProjectCount} at risk and ${overdueProjectCount} overdue`
|
|
: "Project metrics are permission-gated.",
|
|
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
|
|
},
|
|
];
|
|
|
|
const modulePanels = [
|
|
{
|
|
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: "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: "Manufacturing",
|
|
eyebrow: "Execution Load",
|
|
summary:
|
|
snapshot?.workOrders !== null
|
|
? "Work orders, released load, and overdue build pressure are now visible from the dashboard."
|
|
: "Manufacturing read permission is required to surface work-order metrics here.",
|
|
metrics: [
|
|
{ label: "Open work", value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access" },
|
|
{ label: "Released", value: snapshot?.workOrders !== null ? `${releasedWorkOrderCount}` : "No access" },
|
|
{ label: "Overdue", value: snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access" },
|
|
],
|
|
links: [
|
|
{ label: "Open work orders", to: "/manufacturing/work-orders" },
|
|
...(canWriteManufacturing ? [{ label: "New work order", to: "/manufacturing/work-orders/new" }] : []),
|
|
],
|
|
},
|
|
{
|
|
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" },
|
|
{ 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" },
|
|
],
|
|
},
|
|
{
|
|
title: "Projects",
|
|
eyebrow: "Program Control",
|
|
summary:
|
|
snapshot?.projects !== null
|
|
? "Project records now tie customers, commercial documents, shipment context, and delivery ownership into one operational surface."
|
|
: "Project read permission is required to surface program metrics here.",
|
|
metrics: [
|
|
{ label: "Active", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access" },
|
|
{ label: "At risk", value: snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access" },
|
|
{ label: "Overdue", value: snapshot?.projects !== null ? `${overdueProjectCount}` : "No access" },
|
|
],
|
|
links: [
|
|
{ label: "Open projects", to: "/projects" },
|
|
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
|
|
],
|
|
},
|
|
];
|
|
|
|
const futureModules = [
|
|
"Vendor invoice attachments and supplier exception queues",
|
|
"Stock transfers, allocations, and cycle counts",
|
|
"Planning timeline, milestones, and dependency views",
|
|
"Audit trails, diagnostics, and system health checks",
|
|
];
|
|
|
|
return (
|
|
<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">
|
|
<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>
|
|
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
|
|
This landing page now reads directly from live CRM, inventory, manufacturing, sales, shipping, and project data. It is intentionally
|
|
modular so future purchasing, planning, and audit slices can slot into the same command surface without a redesign.
|
|
</p>
|
|
<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">
|
|
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>
|
|
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
|
|
Open projects
|
|
</Link>
|
|
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
|
|
Open manufacturing
|
|
</Link>
|
|
</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 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>
|
|
</div>
|
|
</section>
|
|
<section className="grid gap-3 xl:grid-cols-6">
|
|
{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>
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted">{card.detail}</p>
|
|
</article>
|
|
))}
|
|
</section>
|
|
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-6">
|
|
{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>
|
|
))}
|
|
</section>
|
|
<section className="grid gap-3 xl:grid-cols-5">
|
|
<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">Manufacturing Watch</p>
|
|
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</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 work orders</span>
|
|
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${workOrderCount}` : "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">Active queue</span>
|
|
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "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">Overdue</span>
|
|
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "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">Project Watch</p>
|
|
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</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 projects</span>
|
|
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${projectCount}` : "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">At risk</span>
|
|
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${atRiskProjectCount}` : "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">Overdue</span>
|
|
<span className="font-semibold text-text">{snapshot?.projects !== null ? `${overdueProjectCount}` : "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>
|
|
);
|
|
}
|