From 14708d70138e63e028de27558be3752430548ddc Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 17 Mar 2026 23:52:58 -0500 Subject: [PATCH] planning payload --- CHANGELOG.md | 1 + README.md | 6 +- ROADMAP.md | 4 +- SHIPPED.md | 3 +- .../src/modules/workbench/WorkbenchPage.tsx | 249 +++++- server/src/modules/gantt/service.ts | 803 +++++++++++++----- shared/src/gantt/types.ts | 55 ++ 7 files changed, 875 insertions(+), 246 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa7802..e9f3986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh - Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline - Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer +- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form diff --git a/README.md b/README.md index a09701c..fa4c14d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Current foundation scope includes: - shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments - projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments -- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, and focus drawer +- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, and inline dispatch actions - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts - pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items @@ -126,12 +126,12 @@ Next expansion areas: ## Planning Direction -Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, focus-drawer inspection, and agenda sequencing. +Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, focus-drawer inspection, inline release/build/buy follow-through, and agenda sequencing. Current interactions: - Projects: project timelines and due dates anchor the top-level planning rows -- Manufacturing: open work orders feed task rows, sequencing links, and execution progress +- Manufacturing: open work orders feed task rows, sequencing links, execution progress, release-ready state, and station capacity load - Dashboard: planning now appears as a first-class module with schedule visibility links Next expansion areas: diff --git a/ROADMAP.md b/ROADMAP.md index ad066d5..a4a72b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,13 +102,13 @@ This file tracks work that still needs to be completed. Shipped phase history an ### Planning and scheduling - Task dependencies, milestones, and progress updates -- Manufacturing calendar views and bottleneck visibility +- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries - Labor and machine scheduling support - Theme-compliant workbench scheduling surfaces for light/dark mode - Collapsible schedule groupings and saved planner views - Drag-and-drop rescheduling improvements - Critical-path and overdue highlighting -- Capacity warnings for overloaded work centers +- Richer finite-capacity warnings and rescheduling controls beyond the shipped workbench overload indicators - Better mobile and tablet behavior for shop-floor lookups - Faster filtering by project, customer, work center, and status diff --git a/SHIPPED.md b/SHIPPED.md index 16e8907..da8839a 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -55,7 +55,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Startup brand-theme hydration so Company Settings colors and font persist correctly across refresh - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Live planning workbench timelines driven by project and manufacturing data -- Planning workbench with heatmap, overview, and agenda modes plus exception rail and focus drawer +- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions - Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - Multi-stage Docker packaging and migration-aware entrypoint - Docker image validated locally with successful app startup and login flow @@ -90,6 +90,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Live workbench schedule backed by active projects and open manufacturing work orders - Project due-date milestones, manufacturing sequencing links, and standalone work-queue visibility - Planning exception queue for overdue or at-risk project/manufacturing schedule items +- Station load summaries, release-ready visibility, and inline workbench follow-through actions for release/build/buy dispatch ### Phase 8: Demand planning and supply generation diff --git a/client/src/modules/workbench/WorkbenchPage.tsx b/client/src/modules/workbench/WorkbenchPage.tsx index 19bcb51..a5769ab 100644 --- a/client/src/modules/workbench/WorkbenchPage.tsx +++ b/client/src/modules/workbench/WorkbenchPage.tsx @@ -1,6 +1,13 @@ -import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared"; +import type { + DemandPlanningRollupDto, + GanttTaskDto, + PlanningExceptionDto, + PlanningStationLoadDto, + PlanningTaskActionDto, + PlanningTimelineDto, +} from "@mrp/shared"; import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; import { ApiError, api } from "../../lib/api"; @@ -16,6 +23,23 @@ type FocusRecord = { progress: number; detailHref: string | null; parentId: string | null; + projectId: string | null; + workOrderId: string | null; + salesOrderId: string | null; + itemId: string | null; + itemSku: string | null; + stationId: string | null; + stationCode: string | null; + readinessState: string; + readinessScore: number; + shortageItemCount: number; + totalShortageQuantity: number; + openSupplyQuantity: number; + releaseReady: boolean; + overdue: boolean; + blockedReason: string | null; + utilizationPercent: number | null; + actions: PlanningTaskActionDto[]; }; type HeatmapCell = { @@ -26,6 +50,9 @@ type HeatmapCell = { tasks: FocusRecord[]; }; +type WorkbenchGroup = "projects" | "stations" | "exceptions"; +type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue"; + const DAY_MS = 24 * 60 * 60 * 1000; function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) { @@ -91,15 +118,50 @@ function buildFocusRecords(tasks: GanttTaskDto[]) { progress: task.progress, detailHref: task.detailHref ?? null, parentId: task.parentId ?? null, + projectId: task.projectId ?? null, + workOrderId: task.workOrderId ?? null, + salesOrderId: task.salesOrderId ?? null, + itemId: task.itemId ?? null, + itemSku: task.itemSku ?? null, + stationId: task.stationId ?? null, + stationCode: task.stationCode ?? null, + readinessState: task.readinessState ?? "READY", + readinessScore: task.readinessScore ?? 0, + shortageItemCount: task.shortageItemCount ?? 0, + totalShortageQuantity: task.totalShortageQuantity ?? 0, + openSupplyQuantity: task.openSupplyQuantity ?? 0, + releaseReady: task.releaseReady ?? false, + overdue: task.overdue ?? false, + blockedReason: task.blockedReason ?? null, + utilizationPercent: task.utilizationPercent ?? null, + actions: task.actions ?? [], })); } +function matchesWorkbenchFilter(record: FocusRecord, filter: WorkbenchFilter) { + switch (filter) { + case "release-ready": + return record.releaseReady; + case "blocked": + return record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"; + case "shortage": + return record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE"; + case "overdue": + return record.overdue; + default: + return true; + } +} + export function WorkbenchPage() { + const navigate = useNavigate(); const { token } = useAuth(); const [timeline, setTimeline] = useState(null); const [planningRollup, setPlanningRollup] = useState(null); const [status, setStatus] = useState("Loading live planning timeline..."); const [workbenchMode, setWorkbenchMode] = useState("overview"); + const [workbenchGroup, setWorkbenchGroup] = useState("projects"); + const [workbenchFilter, setWorkbenchFilter] = useState("all"); const [selectedFocusId, setSelectedFocusId] = useState(null); const [selectedHeatmapDate, setSelectedHeatmapDate] = useState(null); @@ -123,9 +185,11 @@ export function WorkbenchPage() { const tasks = timeline?.tasks ?? []; const summary = timeline?.summary; const exceptions = timeline?.exceptions ?? []; + const stationLoads = timeline?.stationLoads ?? []; const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]); + const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]); const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]); - const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : focusRecords[0] ?? null; + const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null; const heatmap = useMemo(() => { const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date()); @@ -135,7 +199,7 @@ export function WorkbenchPage() { cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] }); } - for (const record of focusRecords) { + for (const record of filteredFocusRecords) { if (record.kind === "PROJECT") { continue; } @@ -159,15 +223,16 @@ export function WorkbenchPage() { } return [...cells.values()]; - }, [focusRecords, summary]); + }, [filteredFocusRecords, summary]); const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null; const agendaItems = useMemo( () => [...focusRecords] .filter((record) => record.kind !== "OPERATION") + .filter((record) => matchesWorkbenchFilter(record, workbenchFilter)) .sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime()) .slice(0, 18), - [focusRecords] + [focusRecords, workbenchFilter] ); const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [ @@ -175,6 +240,34 @@ export function WorkbenchPage() { { value: "heatmap", label: "Heatmap", detail: "Load by day" }, { value: "agenda", label: "Agenda", detail: "Upcoming due flow" }, ]; + const groupOptions: Array<{ value: WorkbenchGroup; label: string }> = [ + { value: "projects", label: "Projects" }, + { value: "stations", label: "Stations" }, + { value: "exceptions", label: "Exceptions" }, + ]; + const filterOptions: Array<{ value: WorkbenchFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "release-ready", label: "Release Ready" }, + { value: "blocked", label: "Blocked" }, + { value: "shortage", label: "Shortage" }, + { value: "overdue", label: "Overdue" }, + ]; + + async function handleTaskAction(action: PlanningTaskActionDto) { + if (!token) { + return; + } + if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) { + await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED"); + const refreshed = await api.getPlanningTimeline(token); + setTimeline(refreshed); + setStatus("Workbench refreshed after release."); + return; + } + if (action.href) { + navigate(action.href); + } + } return (
@@ -198,15 +291,32 @@ export function WorkbenchPage() { ))} +
+ {groupOptions.map((option) => ( + + ))} +
+
+ {filterOptions.map((option) => ( + + ))} +
-
+
+ + +
@@ -229,9 +339,9 @@ export function WorkbenchPage() { + ))} @@ -306,54 +430,117 @@ function MetricCard({ label, value }: { label: string; value: string | number }) ); } -function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[]; onSelect: (id: string) => void }) { +function OverviewBoard({ + focusRecords, + stationLoads, + groupMode, + onSelect, +}: { + focusRecords: FocusRecord[]; + stationLoads: PlanningStationLoadDto[]; + groupMode: WorkbenchGroup; + onSelect: (id: string) => void; +}) { const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6); const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10); const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10); + const exceptionRows = focusRecords + .filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY")) + .slice(0, 10); return (

Overview

-

Scan project rollups, active work, and operation load without leaving the planner.

+

Scan project rollups, active work, station load, and release blockers without leaving the planner.

-
-
-

Program Queue

-
- {projects.map((record) => ( - + ))} +
+
+
+

Operation Load

+
+ {operations.map((record) => ( + + ))} +
+
+
+ ) : null} + {groupMode === "stations" ? ( +
+

Work Center Load

+
+ {stationLoads.slice(0, 10).map((station) => ( +
+
+
+
{station.stationCode} - {station.stationName}
+
{station.operationCount} ops across {station.workOrderCount} work orders
+
+
+
{station.utilizationPercent}% util
+
{station.overloaded ? "Overloaded" : "Within load"}
- +
+
Ready {station.readyCount}
+
Blocked {station.blockedCount}
+
Late {station.lateCount}
+
+
))}
+ ) : null} + {groupMode === "exceptions" ? (
-

Operation Load

+

Dispatch Exceptions

- {operations.map((record) => ( + {exceptionRows.map((record) => ( ))}
-
+ ) : null}

Active Work Orders

@@ -361,7 +548,7 @@ function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[] diff --git a/server/src/modules/gantt/service.ts b/server/src/modules/gantt/service.ts index a6ab658..a1fd2b8 100644 --- a/server/src/modules/gantt/service.ts +++ b/server/src/modules/gantt/service.ts @@ -1,8 +1,94 @@ -import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared"; +import type { + GanttLinkDto, + GanttTaskDto, + PlanningReadinessState, + PlanningStationLoadDto, + PlanningTaskActionDto, + PlanningTimelineDto, +} from "@mrp/shared"; import { prisma } from "../../lib/prisma.js"; const DAY_MS = 24 * 60 * 60 * 1000; +const SHIFT_MINUTES_PER_DAY = 8 * 60; + +type PlanningProjectRecord = { + id: string; + projectNumber: string; + name: string; + status: string; + dueDate: Date | null; + createdAt: Date; + customer: { name: string }; + owner: { firstName: string; lastName: string } | null; +}; + +type PlanningWorkOrderRecord = { + id: string; + workOrderNumber: string; + status: string; + quantity: number; + completedQuantity: number; + dueDate: Date | null; + createdAt: Date; + projectId: string | null; + salesOrderId: string | null; + salesOrderLineId: string | null; + warehouseId: string; + locationId: string; + item: { + id: string; + sku: string; + name: string; + type: string; + isPurchasable: boolean; + bomLines: Array<{ + quantity: number; + componentItem: { + id: string; + sku: string; + name: string; + type: string; + isPurchasable: boolean; + }; + }>; + }; + operations: Array<{ + id: string; + sequence: number; + plannedStart: Date; + plannedEnd: Date; + plannedMinutes: number; + station: { id: string; code: string; name: string }; + }>; + materialIssues: Array<{ componentItemId: string; quantity: number }>; +}; + +type WorkOrderInsight = { + readinessState: PlanningReadinessState; + readinessScore: number; + shortageItemCount: number; + totalShortageQuantity: number; + linkedSupplyQuantity: number; + openSupplyQuantity: number; + releaseReady: boolean; + blockedReason: string | null; + overdue: boolean; + actions: PlanningTaskActionDto[]; +}; + +type StationAccumulator = { + stationId: string; + stationCode: string; + stationName: string; + operationCount: number; + workOrderIds: Set; + totalPlannedMinutes: number; + blockedCount: number; + readyCount: number; + lateCount: number; + dayKeys: Set; +}; function clampProgress(value: number) { return Math.max(0, Math.min(100, Math.round(value))); @@ -20,6 +106,10 @@ function endOfDay(value: Date) { return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999); } +function dateKey(value: Date) { + return value.toISOString().slice(0, 10); +} + function projectProgressFromStatus(status: string) { switch (status) { case "COMPLETE": @@ -39,147 +129,402 @@ function workOrderProgress(quantity: number, completedQuantity: number, status: if (status === "COMPLETE") { return 100; } - if (quantity <= 0) { return 0; } - return clampProgress((completedQuantity / quantity) * 100); } function buildOwnerLabel(ownerName: string | null, customerName: string | null) { if (ownerName && customerName) { - return `${ownerName} • ${customerName}`; + return `${ownerName} | ${customerName}`; } - return ownerName ?? customerName ?? null; } +function encodeQuery(params: Record) { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value && value.trim().length > 0) { + search.set(key, value); + } + } + const query = search.toString(); + return query.length > 0 ? `?${query}` : ""; +} + +function isBuildItem(type: string) { + return type === "ASSEMBLY" || type === "MANUFACTURED"; +} + +function shouldBuyItem(type: string, isPurchasable: boolean) { + return type === "PURCHASED" || isPurchasable; +} + +function getAvailabilityKey(itemId: string, warehouseId: string, locationId: string) { + return `${itemId}:${warehouseId}:${locationId}`; +} + +function createStationLoad(record: StationAccumulator): PlanningStationLoadDto { + const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY; + const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0; + return { + stationId: record.stationId, + stationCode: record.stationCode, + stationName: record.stationName, + operationCount: record.operationCount, + workOrderCount: record.workOrderIds.size, + totalPlannedMinutes: record.totalPlannedMinutes, + capacityMinutes, + utilizationPercent, + overloaded: utilizationPercent > 100, + blockedCount: record.blockedCount, + readyCount: record.readyCount, + lateCount: record.lateCount, + }; +} + +function buildProjectTask( + project: PlanningProjectRecord, + projectWorkOrders: PlanningWorkOrderRecord[], + workOrderInsights: Map, + now: Date +): GanttTaskDto { + const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null; + const ownerLabel = buildOwnerLabel(ownerName, project.customer.name); + const dueDates = projectWorkOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value)); + const earliestWorkStart = projectWorkOrders[0]?.createdAt ?? project.createdAt; + const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14); + const insights = projectWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[]; + const readinessState: PlanningReadinessState = + insights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED" + : insights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE" + : insights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY" + : insights.length > 0 && insights.every((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED" + : "READY"; + return { + id: `project-${project.id}`, + text: `${project.projectNumber} - ${project.name}`, + start: startOfDay(earliestWorkStart).toISOString(), + end: endOfDay(lastDueDate).toISOString(), + progress: clampProgress( + projectWorkOrders.length > 0 + ? projectWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / + projectWorkOrders.length + : projectProgressFromStatus(project.status) + ), + type: "project", + entityId: project.id, + projectId: project.id, + status: project.status, + ownerLabel, + detailHref: `/projects/${project.id}`, + readinessState, + readinessScore: insights.length > 0 ? Math.round(insights.reduce((sum, entry) => sum + entry.readinessScore, 0) / insights.length) : 80, + shortageItemCount: insights.reduce((sum, entry) => sum + entry.shortageItemCount, 0), + totalShortageQuantity: insights.reduce((sum, entry) => sum + entry.totalShortageQuantity, 0), + openSupplyQuantity: insights.reduce((sum, entry) => sum + entry.openSupplyQuantity, 0), + releaseReady: insights.some((entry) => entry.releaseReady), + overdue: project.dueDate ? project.dueDate.getTime() < now.getTime() : false, + blockedReason: + readinessState === "BLOCKED" ? "A linked work order is blocked." + : readinessState === "SHORTAGE" ? "Linked work orders have shortages." + : readinessState === "PENDING_SUPPLY" ? "Linked work orders are waiting on supply." + : readinessState === "UNSCHEDULED" ? "Linked work orders are unscheduled." + : null, + actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }], + }; +} + export async function getPlanningTimeline(): Promise { const now = new Date(); - const planningProjects = await prisma.project.findMany({ - where: { - status: { - not: "COMPLETE", + const [projects, workOrders] = await Promise.all([ + prisma.project.findMany({ + where: { status: { not: "COMPLETE" } }, + select: { + id: true, + projectNumber: true, + name: true, + status: true, + dueDate: true, + createdAt: true, + customer: { select: { name: true } }, + owner: { select: { firstName: true, lastName: true } }, }, - }, - include: { - customer: { - select: { - name: true, - }, - }, - owner: { - select: { - firstName: true, - lastName: true, - }, - }, - workOrders: { - where: { - status: { - notIn: ["COMPLETE", "CANCELLED"], - }, - }, - select: { - id: true, - workOrderNumber: true, - status: true, - quantity: true, - completedQuantity: true, - dueDate: true, - createdAt: true, - operations: { - select: { - id: true, - sequence: true, - plannedStart: true, - plannedEnd: true, - plannedMinutes: true, - station: { - select: { - code: true, - name: true, + orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], + }), + prisma.workOrder.findMany({ + where: { status: { notIn: ["COMPLETE", "CANCELLED"] } }, + select: { + id: true, + workOrderNumber: true, + status: true, + quantity: true, + completedQuantity: true, + dueDate: true, + createdAt: true, + projectId: true, + salesOrderId: true, + salesOrderLineId: true, + warehouseId: true, + locationId: true, + item: { + select: { + id: true, + sku: true, + name: true, + type: true, + isPurchasable: true, + bomLines: { + select: { + quantity: true, + componentItem: { + select: { + id: true, + sku: true, + name: true, + type: true, + isPurchasable: true, + }, }, }, - }, - orderBy: [{ sequence: "asc" }], - }, - item: { - select: { - sku: true, - name: true, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], }, }, }, - orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], + operations: { + select: { + id: true, + sequence: true, + plannedStart: true, + plannedEnd: true, + plannedMinutes: true, + station: { select: { id: true, code: true, name: true } }, + }, + orderBy: [{ sequence: "asc" }], + }, + materialIssues: { select: { componentItemId: true, quantity: true } }, }, - }, - orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], - }); + orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], + }), + ]); - const standaloneWorkOrders = await prisma.workOrder.findMany({ - where: { - projectId: null, - status: { - notIn: ["COMPLETE", "CANCELLED"], - }, - }, - include: { - item: { - select: { - sku: true, - name: true, - }, - }, - operations: { - select: { - id: true, - sequence: true, - plannedStart: true, - plannedEnd: true, - plannedMinutes: true, - station: { - select: { - code: true, - name: true, - }, - }, - }, - orderBy: [{ sequence: "asc" }], - }, - }, - orderBy: [{ dueDate: "asc" }, { createdAt: "asc" }], + const planningProjects = projects as PlanningProjectRecord[]; + const openWorkOrders = workOrders as PlanningWorkOrderRecord[]; + const workOrdersByProjectId = new Map(); + const standaloneWorkOrders: PlanningWorkOrderRecord[] = []; + for (const workOrder of openWorkOrders) { + if (workOrder.projectId) { + const existing = workOrdersByProjectId.get(workOrder.projectId) ?? []; + existing.push(workOrder); + workOrdersByProjectId.set(workOrder.projectId, existing); + } else { + standaloneWorkOrders.push(workOrder); + } + } + + const componentItemIds = [...new Set(openWorkOrders.flatMap((workOrder) => workOrder.item.bomLines.map((line) => line.componentItem.id)))]; + const warehouseIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.warehouseId))]; + const locationIds = [...new Set(openWorkOrders.map((workOrder) => workOrder.locationId))]; + + const [transactions, reservations, supplyWorkOrders, purchaseOrderLines] = componentItemIds.length > 0 + ? await Promise.all([ + prisma.inventoryTransaction.findMany({ + where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds } }, + select: { itemId: true, warehouseId: true, locationId: true, transactionType: true, quantity: true }, + }), + prisma.inventoryReservation.findMany({ + where: { itemId: { in: componentItemIds }, warehouseId: { in: warehouseIds }, locationId: { in: locationIds }, status: "ACTIVE" }, + select: { itemId: true, warehouseId: true, locationId: true, quantity: true }, + }), + prisma.workOrder.findMany({ + where: { itemId: { in: componentItemIds }, status: { notIn: ["COMPLETE", "CANCELLED"] } }, + select: { itemId: true, quantity: true, completedQuantity: true }, + }), + prisma.purchaseOrderLine.findMany({ + where: { itemId: { in: componentItemIds }, purchaseOrder: { status: { not: "CLOSED" } } }, + select: { itemId: true, quantity: true, receiptLines: { select: { quantity: true } } }, + }), + ]) + : [[], [], [], []]; + + const availabilityByKey = new Map(); + for (const transaction of transactions) { + const key = getAvailabilityKey(transaction.itemId, transaction.warehouseId, transaction.locationId); + const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 }; + current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity; + availabilityByKey.set(key, current); + } + for (const reservation of reservations) { + if (!reservation.itemId || !reservation.warehouseId || !reservation.locationId) { + continue; + } + const key = getAvailabilityKey(reservation.itemId, reservation.warehouseId, reservation.locationId); + const current = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 }; + current.reservedQuantity += reservation.quantity; + availabilityByKey.set(key, current); + } + + const openWorkSupplyByItemId = new Map(); + for (const workOrder of supplyWorkOrders) { + openWorkSupplyByItemId.set(workOrder.itemId, (openWorkSupplyByItemId.get(workOrder.itemId) ?? 0) + Math.max(workOrder.quantity - workOrder.completedQuantity, 0)); + } + const openPurchaseSupplyByItemId = new Map(); + for (const line of purchaseOrderLines) { + const remainingQuantity = Math.max(line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), 0); + openPurchaseSupplyByItemId.set(line.itemId, (openPurchaseSupplyByItemId.get(line.itemId) ?? 0) + remainingQuantity); + } + + const workOrderInsights = new Map(); + for (const workOrder of openWorkOrders) { + const issuedByComponent = new Map(); + for (const issue of workOrder.materialIssues) { + issuedByComponent.set(issue.componentItemId, (issuedByComponent.get(issue.componentItemId) ?? 0) + issue.quantity); + } + + let shortageItemCount = 0; + let totalShortageQuantity = 0; + let openSupplyQuantity = 0; + let firstShortageAction: PlanningTaskActionDto | null = null; + + for (const line of workOrder.item.bomLines) { + const key = getAvailabilityKey(line.componentItem.id, workOrder.warehouseId, workOrder.locationId); + const availability = availabilityByKey.get(key) ?? { onHandQuantity: 0, reservedQuantity: 0 }; + const availableQuantity = availability.onHandQuantity - availability.reservedQuantity; + const requiredQuantity = line.quantity * workOrder.quantity; + const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0; + const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0); + if (shortageQuantity <= 0) { + continue; + } + shortageItemCount += 1; + totalShortageQuantity += shortageQuantity; + const openSupplyForItem = (openWorkSupplyByItemId.get(line.componentItem.id) ?? 0) + (openPurchaseSupplyByItemId.get(line.componentItem.id) ?? 0); + openSupplyQuantity += openSupplyForItem; + if (!firstShortageAction) { + if (shouldBuyItem(line.componentItem.type, line.componentItem.isPurchasable) && workOrder.salesOrderId) { + firstShortageAction = { + kind: "CREATE_PURCHASE_ORDER", + label: `Buy ${line.componentItem.sku}`, + href: `/purchasing/orders/new${encodeQuery({ planningOrderId: workOrder.salesOrderId, itemId: line.componentItem.id })}`, + itemId: line.componentItem.id, + }; + } else if (isBuildItem(line.componentItem.type)) { + firstShortageAction = { + kind: "CREATE_WORK_ORDER", + label: `Build ${line.componentItem.sku}`, + href: `/manufacturing/work-orders/new${encodeQuery({ + projectId: workOrder.projectId, + itemId: line.componentItem.id, + salesOrderId: workOrder.salesOrderId, + salesOrderLineId: workOrder.salesOrderLineId, + quantity: Math.ceil(shortageQuantity).toString(), + status: "DRAFT", + notes: `Workbench follow-through from ${workOrder.workOrderNumber}`, + })}`, + itemId: line.componentItem.id, + }; + } + } + } + + let readinessState: PlanningReadinessState = "READY"; + let readinessScore = 90; + let blockedReason: string | null = null; + if (workOrder.status === "ON_HOLD") { + readinessState = "BLOCKED"; + readinessScore = 15; + blockedReason = "Work order is on hold."; + } else if (!workOrder.dueDate) { + readinessState = "UNSCHEDULED"; + readinessScore = 25; + blockedReason = "Work order has no due date."; + } else if (totalShortageQuantity > 0 && openSupplyQuantity > 0) { + readinessState = "PENDING_SUPPLY"; + readinessScore = 55; + blockedReason = "Material is short but open supply exists."; + } else if (totalShortageQuantity > 0) { + readinessState = "SHORTAGE"; + readinessScore = 30; + blockedReason = "Material shortage blocks release or execution."; + } else if (workOrder.status === "DRAFT") { + readinessScore = 80; + } + + const releaseReady = workOrder.status === "DRAFT" && totalShortageQuantity === 0 && workOrder.dueDate !== null; + const actions: PlanningTaskActionDto[] = [{ kind: "OPEN_RECORD", label: "Open record", href: `/manufacturing/work-orders/${workOrder.id}`, workOrderId: workOrder.id }]; + if (releaseReady) { + actions.push({ kind: "RELEASE_WORK_ORDER", label: "Release work order", workOrderId: workOrder.id }); + } + if (firstShortageAction) { + actions.push(firstShortageAction); + } + + workOrderInsights.set(workOrder.id, { + readinessState, + readinessScore, + shortageItemCount, + totalShortageQuantity, + linkedSupplyQuantity: 0, + openSupplyQuantity, + releaseReady, + blockedReason, + overdue: workOrder.dueDate ? workOrder.dueDate.getTime() < now.getTime() : false, + actions, + }); + } + + const stationAccumulators = new Map(); + for (const workOrder of openWorkOrders) { + const insight = workOrderInsights.get(workOrder.id); + for (const operation of workOrder.operations) { + const current = stationAccumulators.get(operation.station.id) ?? { + stationId: operation.station.id, + stationCode: operation.station.code, + stationName: operation.station.name, + operationCount: 0, + workOrderIds: new Set(), + totalPlannedMinutes: 0, + blockedCount: 0, + readyCount: 0, + lateCount: 0, + dayKeys: new Set(), + }; + current.operationCount += 1; + current.workOrderIds.add(workOrder.id); + current.totalPlannedMinutes += operation.plannedMinutes; + if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") { + current.blockedCount += 1; + } + if (insight?.releaseReady || insight?.readinessState === "READY") { + current.readyCount += 1; + } + if (insight?.overdue) { + current.lateCount += 1; + } + for (let cursor = startOfDay(operation.plannedStart).getTime(); cursor <= startOfDay(operation.plannedEnd).getTime(); cursor += DAY_MS) { + current.dayKeys.add(dateKey(new Date(cursor))); + } + stationAccumulators.set(operation.station.id, current); + } + } + + const stationLoads = [...stationAccumulators.values()].map(createStationLoad).sort((left, right) => { + if (right.utilizationPercent !== left.utilizationPercent) { + return right.utilizationPercent - left.utilizationPercent; + } + return left.stationCode.localeCompare(right.stationCode); }); + const stationLoadById = new Map(stationLoads.map((load) => [load.stationId, load])); const tasks: GanttTaskDto[] = []; const links: GanttLinkDto[] = []; const exceptions: PlanningTimelineDto["exceptions"] = []; for (const project of planningProjects) { - const ownerName = project.owner ? `${project.owner.firstName} ${project.owner.lastName}`.trim() : null; - const ownerLabel = buildOwnerLabel(ownerName, project.customer.name); - const dueDates = project.workOrders.map((workOrder) => workOrder.dueDate).filter((value): value is Date => Boolean(value)); - const earliestWorkStart = project.workOrders[0]?.createdAt ?? project.createdAt; - const lastDueDate = dueDates.sort((left, right) => left.getTime() - right.getTime()).at(-1) ?? project.dueDate ?? addDays(project.createdAt, 14); - const start = startOfDay(earliestWorkStart); - const end = endOfDay(lastDueDate); - - tasks.push({ - id: `project-${project.id}`, - text: `${project.projectNumber} - ${project.name}`, - start: start.toISOString(), - end: end.toISOString(), - progress: clampProgress( - project.workOrders.length > 0 - ? project.workOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) / project.workOrders.length - : projectProgressFromStatus(project.status) - ), - type: "project", - status: project.status, - ownerLabel, - detailHref: `/projects/${project.id}`, - }); + const projectWorkOrders = workOrdersByProjectId.get(project.id) ?? []; + const projectTask = buildProjectTask(project, projectWorkOrders, workOrderInsights, now); + tasks.push(projectTask); if (project.dueDate) { tasks.push({ @@ -190,56 +535,67 @@ export async function getPlanningTimeline(): Promise { progress: project.status === "COMPLETE" ? 100 : 0, type: "milestone", parentId: `project-${project.id}`, + entityId: project.id, + projectId: project.id, status: project.status, - ownerLabel, + ownerLabel: projectTask.ownerLabel ?? null, detailHref: `/projects/${project.id}`, + readinessState: projectTask.readinessState, + readinessScore: projectTask.readinessScore, + shortageItemCount: projectTask.shortageItemCount, + totalShortageQuantity: projectTask.totalShortageQuantity, + releaseReady: projectTask.releaseReady, + overdue: project.dueDate.getTime() < now.getTime(), + actions: [{ kind: "OPEN_RECORD", label: "Open record", href: `/projects/${project.id}` }], }); - links.push({ - id: `project-link-${project.id}`, - source: `project-${project.id}`, - target: `project-milestone-${project.id}`, - type: "e2e", - }); + links.push({ id: `project-link-${project.id}`, source: `project-${project.id}`, target: `project-milestone-${project.id}`, type: "e2e" }); } let previousTaskId: string | null = null; - for (const workOrder of project.workOrders) { - const workOrderStart = startOfDay(workOrder.createdAt); - const workOrderEnd = endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)); + for (const workOrder of projectWorkOrders) { + const insight = workOrderInsights.get(workOrder.id)!; const workOrderTaskId = `work-order-${workOrder.id}`; tasks.push({ id: workOrderTaskId, text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`, - start: workOrderStart.toISOString(), - end: workOrderEnd.toISOString(), + start: startOfDay(workOrder.createdAt).toISOString(), + end: endOfDay(workOrder.dueDate ?? addDays(workOrder.createdAt, 7)).toISOString(), progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), type: "task", parentId: `project-${project.id}`, + entityId: workOrder.id, + projectId: project.id, + workOrderId: workOrder.id, + salesOrderId: workOrder.salesOrderId, + salesOrderLineId: workOrder.salesOrderLineId, + itemId: workOrder.item.id, + itemSku: workOrder.item.sku, status: workOrder.status, ownerLabel: workOrder.item.name, detailHref: `/manufacturing/work-orders/${workOrder.id}`, + readinessState: insight.readinessState, + readinessScore: insight.readinessScore, + shortageItemCount: insight.shortageItemCount, + totalShortageQuantity: insight.totalShortageQuantity, + linkedSupplyQuantity: insight.linkedSupplyQuantity, + openSupplyQuantity: insight.openSupplyQuantity, + releaseReady: insight.releaseReady, + overdue: insight.overdue, + blockedReason: insight.blockedReason, + loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0), + actions: insight.actions, }); if (previousTaskId) { - links.push({ - id: `sequence-${previousTaskId}-${workOrderTaskId}`, - source: previousTaskId, - target: workOrderTaskId, - type: "e2e", - }); + links.push({ id: `sequence-${previousTaskId}-${workOrderTaskId}`, source: previousTaskId, target: workOrderTaskId, type: "e2e" }); } else { - links.push({ - id: `project-start-${project.id}-${workOrder.id}`, - source: `project-${project.id}`, - target: workOrderTaskId, - type: "e2e", - }); + links.push({ id: `project-start-${project.id}-${workOrder.id}`, source: `project-${project.id}`, target: workOrderTaskId, type: "e2e" }); } - previousTaskId = workOrderTaskId; let previousOperationTaskId: string | null = null; for (const operation of workOrder.operations) { + const stationLoad = stationLoadById.get(operation.station.id) ?? null; const operationTaskId = `work-order-operation-${operation.id}`; tasks.push({ id: operationTaskId, @@ -249,18 +605,34 @@ export async function getPlanningTimeline(): Promise { progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), type: "task", parentId: workOrderTaskId, + entityId: operation.id, + projectId: project.id, + workOrderId: workOrder.id, + salesOrderId: workOrder.salesOrderId, + salesOrderLineId: workOrder.salesOrderLineId, + itemId: workOrder.item.id, + itemSku: workOrder.item.sku, + stationId: operation.station.id, + stationCode: operation.station.code, + stationName: operation.station.name, status: workOrder.status, ownerLabel: workOrder.workOrderNumber, detailHref: `/manufacturing/work-orders/${workOrder.id}`, + readinessState: insight.readinessState, + readinessScore: insight.readinessScore, + shortageItemCount: insight.shortageItemCount, + totalShortageQuantity: insight.totalShortageQuantity, + linkedSupplyQuantity: insight.linkedSupplyQuantity, + openSupplyQuantity: insight.openSupplyQuantity, + releaseReady: insight.releaseReady, + overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(), + blockedReason: insight.blockedReason, + loadMinutes: operation.plannedMinutes, + capacityMinutes: stationLoad?.capacityMinutes ?? null, + utilizationPercent: stationLoad?.utilizationPercent ?? null, + actions: insight.actions, }); - - links.push({ - id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, - source: workOrderTaskId, - target: operationTaskId, - type: "e2e", - }); - + links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" }); if (previousOperationTaskId) { links.push({ id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`, @@ -269,41 +641,30 @@ export async function getPlanningTimeline(): Promise { type: "e2e", }); } - previousOperationTaskId = operationTaskId; } - if (workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()) { + if (insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") { exceptions.push({ id: `work-order-${workOrder.id}`, kind: "WORK_ORDER", title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`, - status: workOrder.status, - dueDate: workOrder.dueDate.toISOString(), + status: insight.readinessState === "READY" ? workOrder.status : insight.readinessState, + dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null, ownerLabel: project.projectNumber, detailHref: `/manufacturing/work-orders/${workOrder.id}`, }); } } - if (project.dueDate && project.dueDate.getTime() < now.getTime()) { - exceptions.push({ - id: `project-${project.id}`, - kind: "PROJECT", - title: `${project.projectNumber} - ${project.name}`, - status: project.status, - dueDate: project.dueDate.toISOString(), - ownerLabel, - detailHref: `/projects/${project.id}`, - }); - } else if (project.status === "AT_RISK") { + if ((project.dueDate && project.dueDate.getTime() < now.getTime()) || project.status === "AT_RISK") { exceptions.push({ id: `project-${project.id}`, kind: "PROJECT", title: `${project.projectNumber} - ${project.name}`, status: project.status, dueDate: project.dueDate ? project.dueDate.toISOString() : null, - ownerLabel, + ownerLabel: projectTask.ownerLabel ?? null, detailHref: `/projects/${project.id}`, }); } @@ -315,14 +676,13 @@ export async function getPlanningTimeline(): Promise { standaloneWorkOrders.reduce((earliest, workOrder) => (workOrder.createdAt < earliest ? workOrder.createdAt : earliest), firstStandaloneWorkOrder.createdAt) ); const bucketEnd = endOfDay( - standaloneWorkOrders.reduce( - (latest, workOrder) => { - const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7); - return candidate > latest ? candidate : latest; - }, - firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7) - ) + standaloneWorkOrders.reduce((latest, workOrder) => { + const candidate = workOrder.dueDate ?? addDays(workOrder.createdAt, 7); + return candidate > latest ? candidate : latest; + }, firstStandaloneWorkOrder.dueDate ?? addDays(firstStandaloneWorkOrder.createdAt, 7)) ); + const standaloneInsights = standaloneWorkOrders.map((workOrder) => workOrderInsights.get(workOrder.id)).filter(Boolean) as WorkOrderInsight[]; + tasks.push({ id: "standalone-manufacturing", text: "Standalone Manufacturing Queue", @@ -336,10 +696,19 @@ export async function getPlanningTimeline(): Promise { status: "ACTIVE", ownerLabel: "Manufacturing", detailHref: "/manufacturing/work-orders", + readinessState: + standaloneInsights.some((entry) => entry.readinessState === "BLOCKED") ? "BLOCKED" + : standaloneInsights.some((entry) => entry.readinessState === "SHORTAGE") ? "SHORTAGE" + : standaloneInsights.some((entry) => entry.readinessState === "PENDING_SUPPLY") ? "PENDING_SUPPLY" + : standaloneInsights.some((entry) => entry.readinessState === "UNSCHEDULED") ? "UNSCHEDULED" + : "READY", + readinessScore: clampProgress(standaloneInsights.reduce((sum, entry) => sum + entry.readinessScore, 0) / standaloneInsights.length), + actions: [{ kind: "OPEN_RECORD", label: "Open record", href: "/manufacturing/work-orders" }], }); let previousStandaloneTaskId: string | null = null; for (const workOrder of standaloneWorkOrders) { + const insight = workOrderInsights.get(workOrder.id)!; const workOrderTaskId = `work-order-${workOrder.id}`; tasks.push({ id: workOrderTaskId, @@ -349,24 +718,35 @@ export async function getPlanningTimeline(): Promise { progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), type: "task", parentId: "standalone-manufacturing", + entityId: workOrder.id, + workOrderId: workOrder.id, + salesOrderId: workOrder.salesOrderId, + salesOrderLineId: workOrder.salesOrderLineId, + itemId: workOrder.item.id, + itemSku: workOrder.item.sku, status: workOrder.status, ownerLabel: workOrder.item.name, detailHref: `/manufacturing/work-orders/${workOrder.id}`, + readinessState: insight.readinessState, + readinessScore: insight.readinessScore, + shortageItemCount: insight.shortageItemCount, + totalShortageQuantity: insight.totalShortageQuantity, + linkedSupplyQuantity: insight.linkedSupplyQuantity, + openSupplyQuantity: insight.openSupplyQuantity, + releaseReady: insight.releaseReady, + overdue: insight.overdue, + blockedReason: insight.blockedReason, + loadMinutes: workOrder.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0), + actions: insight.actions, }); - if (previousStandaloneTaskId) { - links.push({ - id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`, - source: previousStandaloneTaskId, - target: workOrderTaskId, - type: "e2e", - }); + links.push({ id: `sequence-${previousStandaloneTaskId}-${workOrderTaskId}`, source: previousStandaloneTaskId, target: workOrderTaskId, type: "e2e" }); } - previousStandaloneTaskId = workOrderTaskId; let previousOperationTaskId: string | null = null; for (const operation of workOrder.operations) { + const stationLoad = stationLoadById.get(operation.station.id) ?? null; const operationTaskId = `work-order-operation-${operation.id}`; tasks.push({ id: operationTaskId, @@ -376,18 +756,33 @@ export async function getPlanningTimeline(): Promise { progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), type: "task", parentId: workOrderTaskId, + entityId: operation.id, + workOrderId: workOrder.id, + salesOrderId: workOrder.salesOrderId, + salesOrderLineId: workOrder.salesOrderLineId, + itemId: workOrder.item.id, + itemSku: workOrder.item.sku, + stationId: operation.station.id, + stationCode: operation.station.code, + stationName: operation.station.name, status: workOrder.status, ownerLabel: workOrder.workOrderNumber, detailHref: `/manufacturing/work-orders/${workOrder.id}`, + readinessState: insight.readinessState, + readinessScore: insight.readinessScore, + shortageItemCount: insight.shortageItemCount, + totalShortageQuantity: insight.totalShortageQuantity, + linkedSupplyQuantity: insight.linkedSupplyQuantity, + openSupplyQuantity: insight.openSupplyQuantity, + releaseReady: insight.releaseReady, + overdue: insight.overdue || operation.plannedEnd.getTime() < now.getTime(), + blockedReason: insight.blockedReason, + loadMinutes: operation.plannedMinutes, + capacityMinutes: stationLoad?.capacityMinutes ?? null, + utilizationPercent: stationLoad?.utilizationPercent ?? null, + actions: insight.actions, }); - - links.push({ - id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, - source: workOrderTaskId, - target: operationTaskId, - type: "e2e", - }); - + links.push({ id: `work-order-operation-parent-${workOrder.id}-${operation.id}`, source: workOrderTaskId, target: operationTaskId, type: "e2e" }); if (previousOperationTaskId) { links.push({ id: `work-order-operation-sequence-${previousOperationTaskId}-${operationTaskId}`, @@ -396,27 +791,16 @@ export async function getPlanningTimeline(): Promise { type: "e2e", }); } - previousOperationTaskId = operationTaskId; } - if (workOrder.dueDate === null) { + if (workOrder.dueDate === null || insight.overdue || insight.readinessState === "BLOCKED" || insight.readinessState === "SHORTAGE" || insight.readinessState === "PENDING_SUPPLY") { exceptions.push({ - id: `work-order-unscheduled-${workOrder.id}`, + id: workOrder.dueDate === null ? `work-order-unscheduled-${workOrder.id}` : `work-order-${workOrder.id}`, kind: "WORK_ORDER", title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`, - status: workOrder.status, - dueDate: null, - ownerLabel: "No project", - detailHref: `/manufacturing/work-orders/${workOrder.id}`, - }); - } else if (workOrder.dueDate.getTime() < now.getTime()) { - exceptions.push({ - id: `work-order-${workOrder.id}`, - kind: "WORK_ORDER", - title: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`, - status: workOrder.status, - dueDate: workOrder.dueDate.toISOString(), + status: workOrder.dueDate === null ? "UNSCHEDULED" : insight.readinessState === "READY" ? workOrder.status : insight.readinessState, + dueDate: workOrder.dueDate ? workOrder.dueDate.toISOString() : null, ownerLabel: "No project", detailHref: `/manufacturing/work-orders/${workOrder.id}`, }); @@ -435,26 +819,27 @@ export async function getPlanningTimeline(): Promise { activeProjects: planningProjects.filter((project) => project.status === "ACTIVE").length, atRiskProjects: planningProjects.filter((project) => project.status === "AT_RISK").length, overdueProjects: planningProjects.filter((project) => project.dueDate && project.dueDate.getTime() < now.getTime()).length, - activeWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter((workOrder) => - ["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status) - ).length, - overdueWorkOrders: [...planningProjects.flatMap((project) => project.workOrders), ...standaloneWorkOrders].filter( - (workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime() - ).length, - unscheduledWorkOrders: standaloneWorkOrders.filter((workOrder) => workOrder.dueDate === null).length, + activeWorkOrders: openWorkOrders.filter((workOrder) => ["RELEASED", "IN_PROGRESS", "ON_HOLD"].includes(workOrder.status)).length, + overdueWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate && workOrder.dueDate.getTime() < now.getTime()).length, + unscheduledWorkOrders: openWorkOrders.filter((workOrder) => workOrder.dueDate === null).length, + releaseReadyWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.releaseReady).length, + blockedWorkOrders: [...workOrderInsights.values()].filter((insight) => insight.readinessState !== "READY").length, + stationCount: stationLoads.length, + overloadedStations: stationLoads.filter((station) => station.overloaded).length, horizonStart: horizonStart.toISOString(), horizonEnd: horizonEnd.toISOString(), }, exceptions: exceptions .sort((left, right) => { if (!left.dueDate) { - return 1; + return -1; } if (!right.dueDate) { - return -1; + return 1; } return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime(); }) .slice(0, 12), + stationLoads, }; } diff --git a/shared/src/gantt/types.ts b/shared/src/gantt/types.ts index 272438c..43053b1 100644 --- a/shared/src/gantt/types.ts +++ b/shared/src/gantt/types.ts @@ -1,3 +1,15 @@ +export const planningReadinessStates = ["READY", "SHORTAGE", "PENDING_SUPPLY", "UNSCHEDULED", "BLOCKED"] as const; + +export type PlanningReadinessState = (typeof planningReadinessStates)[number]; + +export interface PlanningTaskActionDto { + kind: "OPEN_RECORD" | "RELEASE_WORK_ORDER" | "CREATE_WORK_ORDER" | "CREATE_PURCHASE_ORDER"; + label: string; + href?: string | null; + workOrderId?: string | null; + itemId?: string | null; +} + export interface GanttTaskDto { id: string; text: string; @@ -9,6 +21,29 @@ export interface GanttTaskDto { status?: string; ownerLabel?: string | null; detailHref?: string | null; + entityId?: string | null; + projectId?: string | null; + workOrderId?: string | null; + salesOrderId?: string | null; + salesOrderLineId?: string | null; + itemId?: string | null; + itemSku?: string | null; + stationId?: string | null; + stationCode?: string | null; + stationName?: string | null; + readinessState?: PlanningReadinessState; + readinessScore?: number; + shortageItemCount?: number; + totalShortageQuantity?: number; + linkedSupplyQuantity?: number; + openSupplyQuantity?: number; + releaseReady?: boolean; + overdue?: boolean; + blockedReason?: string | null; + loadMinutes?: number; + capacityMinutes?: number | null; + utilizationPercent?: number | null; + actions?: PlanningTaskActionDto[]; } export interface GanttLinkDto { @@ -25,6 +60,10 @@ export interface PlanningSummaryDto { activeWorkOrders: number; overdueWorkOrders: number; unscheduledWorkOrders: number; + releaseReadyWorkOrders: number; + blockedWorkOrders: number; + stationCount: number; + overloadedStations: number; horizonStart: string; horizonEnd: string; } @@ -39,9 +78,25 @@ export interface PlanningExceptionDto { detailHref: string; } +export interface PlanningStationLoadDto { + stationId: string; + stationCode: string; + stationName: string; + operationCount: number; + workOrderCount: number; + totalPlannedMinutes: number; + capacityMinutes: number; + utilizationPercent: number; + overloaded: boolean; + blockedCount: number; + readyCount: number; + lateCount: number; +} + export interface PlanningTimelineDto { tasks: GanttTaskDto[]; links: GanttLinkDto[]; summary: PlanningSummaryDto; exceptions: PlanningExceptionDto[]; + stationLoads: PlanningStationLoadDto[]; }