diff --git a/client/src/modules/dashboard/DashboardPage.tsx b/client/src/modules/dashboard/DashboardPage.tsx index 592061b..3cf6ede 100644 --- a/client/src/modules/dashboard/DashboardPage.tsx +++ b/client/src/modules/dashboard/DashboardPage.tsx @@ -1,7 +1,7 @@ import { permissions } from "@mrp/shared"; import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js"; import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import type { ReactNode } from "react"; import { useAuth } from "../../auth/AuthProvider"; import { ApiError, api } from "../../lib/api"; @@ -37,14 +37,74 @@ function sumNumber(values: number[]) { return values.reduce((total, value) => total + value, 0); } +function formatPercent(value: number, total: number) { + if (total <= 0) { + return "0%"; + } + + return `${Math.round((value / total) * 100)}%`; +} + +function ProgressBar({ + value, + total, + tone, +}: { + value: number; + total: number; + tone: string; +}) { + const width = total > 0 ? Math.max(6, Math.round((value / total) * 100)) : 0; + + return ( +
+
+
+ ); +} + +function StackedBar({ + segments, +}: { + segments: Array<{ value: number; tone: string }>; +}) { + const total = segments.reduce((sum, segment) => sum + segment.value, 0); + + return ( +
+ {segments.map((segment, index) => { + const width = total > 0 ? (segment.value / total) * 100 : 0; + return
; + })} +
+ ); +} + +function DashboardCard({ + eyebrow, + title, + children, + className = "", +}: { + eyebrow: string; + title: string; + children: ReactNode; + className?: string; +}) { + return ( +
+

{eyebrow}

+

{title}

+ {children} +
+ ); +} + export function DashboardPage() { const { token, user } = useAuth(); const [snapshot, setSnapshot] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite); - const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite); - const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead); useEffect(() => { if (!token || !user) { @@ -76,9 +136,9 @@ export function DashboardPage() { 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), - canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null), + canReadShipping ? api.getShipments(authToken) : Promise.resolve(null), + canReadProjects ? api.getProjects(authToken) : Promise.resolve(null), + canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null), ]); if (!isMounted) { @@ -136,14 +196,14 @@ export function DashboardPage() { const planningRollup = snapshot?.planningRollup; const customerCount = customers.length; - const resellerCount = customers.filter((customer) => customer.isReseller).length; const activeCustomerCount = customers.filter((customer) => customer.lifecycleStage === "ACTIVE").length; + const resellerCount = customers.filter((customer) => customer.isReseller).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 assemblyCount = items.filter((item) => item.type === "ASSEMBLY" || item.type === "MANUFACTURED").length; const obsoleteItemCount = items.filter((item) => item.status === "OBSOLETE").length; const warehouseCount = warehouses.length; const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount)); @@ -151,16 +211,19 @@ export function DashboardPage() { const purchaseOrderCount = purchaseOrders.length; const openPurchaseOrderCount = purchaseOrders.filter((order) => order.status !== "CLOSED").length; const issuedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "ISSUED" || order.status === "APPROVED").length; + const closedPurchaseOrderCount = purchaseOrders.filter((order) => order.status === "CLOSED").length; const purchaseOrderValue = sumNumber(purchaseOrders.map((order) => order.total)); 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 inProgressWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "IN_PROGRESS").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 approvedQuoteCount = quotes.filter((quote) => quote.status === "APPROVED").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)); @@ -180,309 +243,350 @@ export function DashboardPage() { return new Date(project.dueDate).getTime() < Date.now(); }).length; + const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0; const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0; const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0; const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0; + const planningItemCount = planningRollup?.summary.itemCount ?? 0; const metricCards = [ { - label: "CRM Accounts", - value: snapshot?.customers !== null ? `${customerCount}` : "No access", - tone: "border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300", + label: "Accounts", + value: snapshot?.customers !== null ? `${customerCount + vendorCount}` : "No access", + secondary: snapshot?.customers !== null ? `${activeCustomerCount} active customers` : "", + tone: "bg-emerald-500", }, { - label: "Inventory Footprint", + label: "Inventory", value: snapshot?.items !== null ? `${itemCount}` : "No access", - tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300", + secondary: snapshot?.items !== null ? `${assemblyCount} buildable items` : "", + tone: "bg-sky-500", }, { - label: "Purchasing Queue", - value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access", - tone: "border-teal-400/30 bg-teal-500/12 text-teal-700 dark:text-teal-300", + label: "Open Supply", + value: snapshot?.purchaseOrders !== null || snapshot?.workOrders !== null ? `${openPurchaseOrderCount + activeWorkOrderCount}` : "No access", + secondary: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount} PO | ${activeWorkOrderCount} WO` : "", + tone: "bg-teal-500", }, { - label: "Manufacturing Load", - value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access", - tone: "border-indigo-400/30 bg-indigo-500/12 text-indigo-700 dark:text-indigo-300", - }, - { - label: "Commercial Value", + label: "Commercial", value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access", - tone: "border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300", + secondary: snapshot?.orders !== null ? `${orderCount} orders live` : "", + tone: "bg-amber-500", }, { - label: "Shipping Queue", - value: snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access", - tone: "border-brand/30 bg-brand/10 text-brand", - }, - { - label: "Project Load", + label: "Projects", value: snapshot?.projects !== null ? `${activeProjectCount}` : "No access", - tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300", + secondary: snapshot?.projects !== null ? `${atRiskProjectCount} at risk` : "", + tone: "bg-violet-500", }, { - label: "Material Readiness", + label: "Readiness", value: planningRollup ? `${shortageItemCount}` : "No access", - tone: "border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300", - }, - ]; - - const modulePanels = [ - { - title: "CRM", - 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", - 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: "Purchasing", - metrics: [ - { label: "Open POs", value: snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access" }, - { label: "Issued", value: snapshot?.purchaseOrders !== null ? `${issuedPurchaseOrderCount}` : "No access" }, - { label: "Committed", value: snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access" }, - ], - links: [ - { label: "Open purchase orders", to: "/purchasing/orders" }, - ], - }, - { - title: "Manufacturing", - 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", - 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", - 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", - 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" }] : []), - ], - }, - { - title: "Planning", - metrics: [ - { label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" }, - { label: "Shortage items", value: canReadPlanning && planningRollup ? `${shortageItemCount}` : "No access" }, - { label: "Build / buy", value: canReadPlanning && planningRollup ? `${buildRecommendationCount} / ${buyRecommendationCount}` : "No access" }, - ], - links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [], + secondary: planningRollup ? `${totalUncoveredQuantity} units uncovered` : "", + tone: "bg-rose-500", }, ]; return (
{error ?
{error}
: null} -
+
{metricCards.map((card) => (

{card.label}

-
-
{card.value}
- Live +
{isLoading ? "Loading..." : card.value}
+
+
+
+
+ Live
+ {card.secondary ?
{card.secondary}
: null}
))}
-
- {modulePanels.map((panel) => ( -
-

{panel.title}

-
- {panel.metrics.map((metric) => ( -
- {metric.label} - {metric.value} +
+ +
+
+
Quotes
+
{snapshot?.quotes !== null ? formatCurrency(quoteValue) : "No access"}
+
+
+ Draft + {draftQuoteCount}
- ))} + +
+ Approved + {approvedQuoteCount} +
+ +
-
- {panel.links.map((link) => ( - - {link.label} - - ))} +
+
Orders
+
{snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"}
+
+
+
Issued / approved
+
{issuedOrderCount}
+
+
+
Total orders
+
{orderCount}
+
+
+
+ +
-
- ))} +
+ + +
+
+
+ Active customers + {snapshot?.customers !== null ? formatPercent(activeCustomerCount, Math.max(customerCount, 1)) : "No access"} +
+
+ +
+
+
+
+
Customers
+
{customerCount}
+
+
+
Resellers
+
{resellerCount}
+
+
+
Vendors
+
{vendorCount}
+
+
+
+
+ Strategic accounts + {strategicCustomerCount} +
+
+ +
+
+
+
-
-
-

Planning

-
-
- Shortage items - {planningRollup ? `${shortageItemCount}` : "No access"} +
+ +
+
+
Item mix
+
+
+
+ Active items + {activeItemCount} +
+
+ +
+
+
+
+ Buildable items + {assemblyCount} +
+
+ +
+
+
+
+ Obsolete items + {obsoleteItemCount} +
+
+ +
+
+
-
- Build recommendations - {planningRollup ? `${buildRecommendationCount}` : "No access"} -
-
- Buy recommendations - {planningRollup ? `${buyRecommendationCount}` : "No access"} -
-
- Uncovered qty - {planningRollup ? `${totalUncoveredQuantity}` : "No access"} +
+
Storage surface
+
+
+
Warehouses
+
{warehouseCount}
+
+
+
Locations
+
{locationCount}
+
+
-
-
-

Inventory

-
-
- Obsolete items - {snapshot?.items !== null ? `${obsoleteItemCount}` : "No access"} + + +
+
Open workload split
+
+
-
- Warehouse count - {snapshot?.warehouses !== null ? `${warehouseCount}` : "No access"} -
-
- Stock locations - {snapshot?.warehouses !== null ? `${locationCount}` : "No access"} +
+
+
Open PO queue
+
{openPurchaseOrderCount}
+
{formatCurrency(purchaseOrderValue)} committed
+
+
+
Active work orders
+
{activeWorkOrderCount}
+
{overdueWorkOrderCount} overdue
+
+
+
Issued / approved POs
+
{issuedPurchaseOrderCount}
+
+
+
Released WOs
+
{releasedWorkOrderCount}
+
-
-
-

Sales

-
-
- Issued orders - {snapshot?.orders !== null ? `${issuedOrderCount}` : "No access"} + + +
+
+
+ Shortage items + {planningRollup ? shortageItemCount : "No access"} +
+
+ +
-
- Draft quotes - {snapshot?.quotes !== null ? `${draftQuoteCount}` : "No access"} +
+
Build vs buy
+
+ +
+
+
+
Build recommendations
+
{planningRollup ? buildRecommendationCount : "No access"}
+
+
+
Buy recommendations
+
{planningRollup ? buyRecommendationCount : "No access"}
+
+
-
- Order backlog - {snapshot?.orders !== null ? formatCurrency(orderValue) : "No access"} +
+
Uncovered quantity
+
{planningRollup ? totalUncoveredQuantity : "No access"}
-
-
-

Purchasing

-
-
- Total purchase orders - {snapshot?.purchaseOrders !== null ? `${purchaseOrderCount}` : "No access"} + +
+
+ +
+
+
Projects
+
+ +
+
+
+
Active
+
{activeProjectCount}
+
+
+
At risk
+
{atRiskProjectCount}
+
+
+
Overdue
+
{overdueProjectCount}
+
+
-
- Open queue - {snapshot?.purchaseOrders !== null ? `${openPurchaseOrderCount}` : "No access"} -
-
- Committed value - {snapshot?.purchaseOrders !== null ? formatCurrency(purchaseOrderValue) : "No access"} +
+
Shipping
+
+ +
+
+
+
Open
+
{activeShipmentCount}
+
+
+
In transit
+
{inTransitCount}
+
+
+
Delivered
+
{deliveredCount}
+
+
- -
-

Manufacturing

-
-
- Total work orders - {snapshot?.workOrders !== null ? `${workOrderCount}` : "No access"} -
-
- Active queue - {snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access"} -
-
- Overdue - {snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access"} -
+ + +
+ {[ + { label: "Customers", value: customerCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-emerald-500" }, + { label: "Inventory items", value: itemCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-sky-500" }, + { label: "Sales orders", value: orderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-amber-500" }, + { label: "Purchase orders", value: purchaseOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-teal-500" }, + { label: "Work orders", value: workOrderCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-indigo-500" }, + { label: "Shipments", value: shipmentCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-brand" }, + { label: "Projects", value: projectCount, total: Math.max(customerCount, vendorCount, itemCount, orderCount, purchaseOrderCount, workOrderCount, shipmentCount, projectCount, 1), tone: "bg-violet-500" }, + ].map((row) => ( +
+
+ {row.label} + {row.value} +
+
+ +
+
+ ))}
-
-
-

Projects

-
-
- Total projects - {snapshot?.projects !== null ? `${projectCount}` : "No access"} -
-
- At risk - {snapshot?.projects !== null ? `${atRiskProjectCount}` : "No access"} -
-
- Overdue - {snapshot?.projects !== null ? `${overdueProjectCount}` : "No access"} -
-
-
-
-

Shipping

-
-
- Total shipments - {snapshot?.shipments !== null ? `${shipmentCount}` : "No access"} -
-
- Open queue - {snapshot?.shipments !== null ? `${activeShipmentCount}` : "No access"} -
-
- Delivered - {snapshot?.shipments !== null ? `${deliveredCount}` : "No access"} -
-
-
+ {snapshot ?
Refreshed {new Date(snapshot.refreshedAt).toLocaleString()}
: null} +
);