planning payload

This commit is contained in:
2026-03-17 23:52:58 -05:00
parent 66d8814d89
commit 14708d7013
7 changed files with 875 additions and 246 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline...");
const [workbenchMode, setWorkbenchMode] = useState<WorkbenchMode>("overview");
const [workbenchGroup, setWorkbenchGroup] = useState<WorkbenchGroup>("projects");
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(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 (
<section className="space-y-4">
@@ -198,15 +291,32 @@ export function WorkbenchPage() {
</button>
))}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{groupOptions.map((option) => (
<button key={option.value} type="button" onClick={() => setWorkbenchGroup(option.value)} className={`rounded-2xl border px-3 py-2 text-sm font-semibold ${workbenchGroup === option.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-muted"}`}>
{option.label}
</button>
))}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{filterOptions.map((option) => (
<button key={option.value} type="button" onClick={() => setWorkbenchFilter(option.value)} className={`rounded-2xl border px-3 py-2 text-sm font-semibold ${workbenchFilter === option.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-muted"}`}>
{option.label}
</button>
))}
</div>
</div>
<section className="grid gap-3 xl:grid-cols-8">
<section className="grid gap-3 xl:grid-cols-10">
<MetricCard label="Active Projects" value={summary?.activeProjects ?? 0} />
<MetricCard label="At Risk" value={summary?.atRiskProjects ?? 0} />
<MetricCard label="Overdue Projects" value={summary?.overdueProjects ?? 0} />
<MetricCard label="Active Work" value={summary?.activeWorkOrders ?? 0} />
<MetricCard label="Overdue Work" value={summary?.overdueWorkOrders ?? 0} />
<MetricCard label="Unscheduled" value={summary?.unscheduledWorkOrders ?? 0} />
<MetricCard label="Release Ready" value={summary?.releaseReadyWorkOrders ?? 0} />
<MetricCard label="Blocked Work" value={summary?.blockedWorkOrders ?? 0} />
<MetricCard label="Stations" value={`${summary?.stationCount ?? 0} / ${summary?.overloadedStations ?? 0} hot`} />
<MetricCard label="Shortage Items" value={planningRollup?.summary.uncoveredItemCount ?? 0} />
<MetricCard label="Build / Buy" value={planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"} />
</section>
@@ -229,9 +339,9 @@ export function WorkbenchPage() {
<button key={exception.id} type="button" onClick={() => setSelectedFocusId(exception.id.startsWith("project-") ? exception.id : exception.id.replace("work-order-unscheduled-", "work-order-"))} className="block w-full rounded-[18px] border border-line/70 bg-page/60 p-3 text-left transition hover:bg-page/80">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
<div className="mt-1 font-semibold text-text">{exception.title}</div>
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
<div className="mt-1 font-semibold text-text">{exception.title}</div>
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
</div>
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">{exception.status.replaceAll("_", " ")}</span>
</div>
@@ -246,6 +356,7 @@ export function WorkbenchPage() {
<div className="mt-4 space-y-2 rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3"><span className="text-muted">Uncovered quantity</span><span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span></div>
<div className="flex items-center justify-between gap-3"><span className="text-muted">Projects with linked demand</span><span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span></div>
<div className="flex items-center justify-between gap-3"><span className="text-muted">Overloaded stations</span><span className="font-semibold text-text">{summary?.overloadedStations ?? 0}</span></div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open projects</Link>
@@ -256,7 +367,7 @@ export function WorkbenchPage() {
</aside>
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel">
{workbenchMode === "overview" ? <OverviewBoard focusRecords={focusRecords} onSelect={setSelectedFocusId} /> : null}
{workbenchMode === "overview" ? <OverviewBoard focusRecords={filteredFocusRecords} stationLoads={stationLoads} groupMode={workbenchGroup} onSelect={setSelectedFocusId} /> : null}
{workbenchMode === "heatmap" ? <HeatmapBoard heatmap={heatmap} selectedDate={selectedHeatmapDate} onSelectDate={setSelectedHeatmapDate} /> : null}
{workbenchMode === "agenda" ? <AgendaBoard records={agendaItems} onSelect={setSelectedFocusId} /> : null}
</div>
@@ -273,11 +384,24 @@ export function WorkbenchPage() {
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3"><span className="text-muted">Status</span><span className="font-semibold text-text">{selectedFocus.status.replaceAll("_", " ")}</span></div>
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Readiness</span><span className="font-semibold text-text">{selectedFocus.readinessState.replaceAll("_", " ")} ({selectedFocus.readinessScore}%)</span></div>
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Window</span><span className="font-semibold text-text">{formatDate(selectedFocus.start)} - {formatDate(selectedFocus.end)}</span></div>
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Progress</span><span className="font-semibold text-text">{selectedFocus.progress}%</span></div>
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Shortage</span><span className="font-semibold text-text">{selectedFocus.shortageItemCount} / {selectedFocus.totalShortageQuantity}</span></div>
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Open supply</span><span className="font-semibold text-text">{selectedFocus.openSupplyQuantity}</span></div>
{selectedFocus.blockedReason ? <div className="mt-3 text-xs text-muted">{selectedFocus.blockedReason}</div> : null}
</div>
<div className="flex flex-wrap gap-2">
{selectedFocus.detailHref ? <Link to={selectedFocus.detailHref} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Open record</Link> : null}
{selectedFocus.actions.map((action, index) => (
<button
key={`${action.kind}-${index}`}
type="button"
onClick={() => void handleTaskAction(action)}
className={`${index === 0 ? "bg-brand text-white" : "border border-line/70 text-text"} rounded-2xl px-2 py-2 text-sm font-semibold`}
>
{action.label}
</button>
))}
<button type="button" onClick={() => setWorkbenchMode("heatmap")} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">View load</button>
</div>
</div>
@@ -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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Overview</p>
<p className="mt-2 text-sm text-muted">Scan project rollups, active work, and operation load without leaving the planner.</p>
<p className="mt-2 text-sm text-muted">Scan project rollups, active work, station load, and release blockers without leaving the planner.</p>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<div className="mt-3 space-y-3">
{projects.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="block w-full rounded-[16px] border border-line/70 bg-surface/80 p-3 text-left transition hover:bg-surface">
<div className="flex flex-wrap items-center justify-between gap-3">
{groupMode === "projects" ? (
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<div className="mt-3 space-y-3">
{projects.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="block w-full rounded-[16px] border border-line/70 bg-surface/80 p-3 text-left transition hover:bg-surface">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.readinessState}</div>
<div>{record.progress}% progress</div>
</div>
</div>
</button>
))}
</div>
</section>
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<div className="mt-3 space-y-2">
{operations.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-surface/80 px-3 py-2 text-left transition hover:bg-surface">
<div>
<div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No owner context"}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.status.replaceAll("_", " ")}</div>
<div>{record.progress}% progress</div>
<div>{record.stationCode ?? "No station"}</div>
<div>{record.utilizationPercent ?? 0}% util</div>
</div>
</button>
))}
</div>
</section>
</div>
) : null}
{groupMode === "stations" ? (
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Work Center Load</p>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{stationLoads.slice(0, 10).map((station) => (
<div key={station.stationId} className="rounded-[16px] border border-line/70 bg-surface/80 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{station.stationCode} - {station.stationName}</div>
<div className="mt-1 text-xs text-muted">{station.operationCount} ops across {station.workOrderCount} work orders</div>
</div>
<div className="text-right text-xs text-muted">
<div>{station.utilizationPercent}% util</div>
<div>{station.overloaded ? "Overloaded" : "Within load"}</div>
</div>
</div>
</button>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted">
<div>Ready {station.readyCount}</div>
<div>Blocked {station.blockedCount}</div>
<div>Late {station.lateCount}</div>
</div>
</div>
))}
</div>
</section>
) : null}
{groupMode === "exceptions" ? (
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
<div className="mt-3 space-y-2">
{operations.map((record) => (
{exceptionRows.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="flex w-full items-center justify-between gap-3 rounded-[16px] border border-line/70 bg-surface/80 px-3 py-2 text-left transition hover:bg-surface">
<div>
<div className="font-semibold text-text">{record.title}</div>
<div className="mt-1 text-xs text-muted">{record.ownerLabel ?? "No parent work order"}</div>
<div className="mt-1 text-xs text-muted">{record.readinessState} - shortage {record.totalShortageQuantity}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{record.ownerLabel ?? "No context"}</div>
<div>{record.overdue ? "Overdue" : "Open"}</div>
</div>
<div className="text-xs text-muted">{formatDate(record.start)} - {formatDate(record.end)}</div>
</button>
))}
</div>
</section>
</div>
) : null}
<section className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
@@ -361,7 +548,7 @@ function OverviewBoard({ focusRecords, onSelect }: { focusRecords: FocusRecord[]
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className="rounded-[16px] border border-line/70 bg-surface/80 p-3 text-left transition hover:bg-surface">
<div className="font-semibold text-text">{record.title}</div>
<div className="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
<span>{record.status.replaceAll("_", " ")}</span>
<span>{record.readinessState}</span>
<span>{record.progress}%</span>
</div>
</button>

View File

@@ -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<string>;
totalPlannedMinutes: number;
blockedCount: number;
readyCount: number;
lateCount: number;
dayKeys: Set<string>;
};
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<string, string | null | undefined>) {
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<string, WorkOrderInsight>,
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<PlanningTimelineDto> {
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<string, PlanningWorkOrderRecord[]>();
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<string, { onHandQuantity: number; reservedQuantity: number }>();
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<string, number>();
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<string, number>();
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<string, WorkOrderInsight>();
for (const workOrder of openWorkOrders) {
const issuedByComponent = new Map<string, number>();
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<string, StationAccumulator>();
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<string>(),
totalPlannedMinutes: 0,
blockedCount: 0,
readyCount: 0,
lateCount: 0,
dayKeys: new Set<string>(),
};
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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<PlanningTimelineDto> {
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,
};
}

View File

@@ -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[];
}