workbench rebalance

This commit is contained in:
2026-03-18 00:10:15 -05:00
parent 14708d7013
commit abc795b4a7
14 changed files with 640 additions and 48 deletions

View File

@@ -9,6 +9,9 @@ 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
- Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page
- Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
- 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

@@ -27,8 +27,8 @@ Current foundation scope includes:
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
- 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, focus drawer, station load grouping, readiness filters, and inline dispatch actions
- manufacturing work orders with project linkage, station-based operation templates, station calendars/capacity settings, calendar-aware operation scheduling, material issue posting, completion posting, operation rescheduling, and work-order attachments
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, and planner-side operation rebalance controls including station-to-station moves
- 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
@@ -110,7 +110,7 @@ Next expansion areas:
## Manufacturing Direction
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, automatic work-order operation plans, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Current interactions:
@@ -122,11 +122,11 @@ Next expansion areas:
- Purchasing: shortages and buyout demand should surface from manufacturing execution
- Shipping: completed manufacturing should feed shipment readiness
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
- Planning: manufacturing orders, routings, and work centers now drive the first capacity/load layer and should continue expanding into fuller finite-capacity scheduling
## 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, station load summaries, readiness scoring, focus-drawer inspection, inline release/build/buy follow-through, 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, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, inline release/build/buy follow-through, and agenda sequencing.
Current interactions:

View File

@@ -103,12 +103,12 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Task dependencies, milestones, and progress updates
- Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries
- Labor and machine scheduling support
- Labor and machine scheduling support beyond the shipped station calendar/capacity foundation
- Theme-compliant workbench scheduling surfaces for light/dark mode
- Collapsible schedule groupings and saved planner views
- Drag-and-drop rescheduling improvements
- Conflict-aware drag-and-drop rescheduling improvements beyond the shipped planner-side station reassignment controls
- Critical-path and overdue highlighting
- Richer finite-capacity warnings and rescheduling controls beyond the shipped workbench overload indicators
- Richer finite-capacity warnings, automated rebalance logic, and station drag-rescheduling beyond the shipped overload indicators and workbench rebalance controls
- Better mobile and tablet behavior for shop-floor lookups
- Faster filtering by project, customer, work center, and status

View File

@@ -37,7 +37,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
- Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
- Manufacturing stations, item routing templates, and automatic work-order operation planning for the workbench schedule
- Manufacturing stations, item routing templates, station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- Revision comparison UX for changed sales and purchasing documents, including purchase-order revision persistence
@@ -56,6 +56,8 @@ This file tracks roadmap phases, slices, and major foundations that have already
- 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, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, and station-to-station reassignment
- 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

View File

@@ -61,6 +61,7 @@ import type {
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationScheduleInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -639,6 +640,13 @@ export const api = {
token
);
},
updateWorkOrderOperationSchedule(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationScheduleInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/schedule`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,

View File

@@ -10,6 +10,9 @@ const emptyStationInput: ManufacturingStationInput = {
name: "",
description: "",
queueDays: 0,
dailyCapacityMinutes: 480,
parallelCapacity: 1,
workingDays: [1, 2, 3, 4, 5],
isActive: true,
};
@@ -72,6 +75,8 @@ export function ManufacturingPage() {
</div>
<div className="text-right text-xs text-muted">
<div>{station.queueDays} expected wait day(s)</div>
<div>{station.dailyCapacityMinutes} min/day x {station.parallelCapacity}</div>
<div>Days {station.workingDays.join(",")}</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div>
</div>
@@ -96,6 +101,46 @@ export function ManufacturingPage() {
<span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Capacity Minutes / Day</span>
<input type="number" min={60} step={30} value={form.dailyCapacityMinutes} onChange={(event) => setForm((current) => ({ ...current, dailyCapacityMinutes: Number.parseInt(event.target.value, 10) || 480 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Parallel Capacity</span>
<input type="number" min={1} step={1} value={form.parallelCapacity} onChange={(event) => setForm((current) => ({ ...current, parallelCapacity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Working Days</span>
<div className="flex flex-wrap gap-2">
{[
{ value: 1, label: "Mon" },
{ value: 2, label: "Tue" },
{ value: 3, label: "Wed" },
{ value: 4, label: "Thu" },
{ value: 5, label: "Fri" },
{ value: 6, label: "Sat" },
{ value: 0, label: "Sun" },
].map((day) => (
<label key={day.value} className="flex items-center gap-2 rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text">
<input
type="checkbox"
checked={form.workingDays.includes(day.value)}
onChange={(event) =>
setForm((current) => ({
...current,
workingDays: event.target.checked
? [...current.workingDays, day.value].sort((left, right) => left - right)
: current.workingDays.filter((value) => value !== day.value),
}))
}
/>
<span>{day.label}</span>
</label>
))}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderOperationScheduleInput, WorkOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -22,6 +22,8 @@ export function WorkOrderDetailPage() {
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "issue" | "completion";
@@ -56,6 +58,11 @@ export function WorkOrderDetailPage() {
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
@@ -137,6 +144,35 @@ export function WorkOrderDetailPage() {
}
}
async function submitOperationReschedule(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationScheduleForm[operationId];
if (!payload?.plannedStart) {
return;
}
setReschedulingOperationId(operationId);
setStatus("Rebuilding operation schedule...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationSchedule(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setStatus("Operation schedule updated with station calendar constraints.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to reschedule operation.";
setStatus(message);
} finally {
setReschedulingOperationId(null);
}
}
function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) {
return;
@@ -271,9 +307,11 @@ export function WorkOrderDetailPage() {
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Seq</th>
<th className="px-3 py-3">Station</th>
<th className="px-3 py-3">Capacity</th>
<th className="px-3 py-3">Start</th>
<th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th>
{canManage ? <th className="px-3 py-3">Reschedule</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-line/70">
@@ -284,9 +322,38 @@ export function WorkOrderDetailPage() {
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-3 py-3 text-xs text-muted">
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
<div>{operation.stationWorkingDays.join(",")}</div>
</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td>
{canManage ? (
<td className="px-3 py-3">
<div className="flex min-w-[220px] items-center gap-2">
<input
type="datetime-local"
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
onChange={(event) =>
setOperationScheduleForm((current) => ({
...current,
[operation.id]: { plannedStart: new Date(event.target.value).toISOString() },
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<button
type="button"
onClick={() => void submitOperationReschedule(operation.id)}
disabled={reschedulingOperationId === operation.id}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{reschedulingOperationId === operation.id ? "Saving..." : "Apply"}
</button>
</div>
</td>
) : null}
</tr>
))}
</tbody>

View File

@@ -1,6 +1,7 @@
import type {
DemandPlanningRollupDto,
GanttTaskDto,
ManufacturingStationDto,
PlanningExceptionDto,
PlanningStationLoadDto,
PlanningTaskActionDto,
@@ -14,6 +15,7 @@ import { ApiError, api } from "../../lib/api";
type WorkbenchMode = "overview" | "heatmap" | "agenda";
type FocusRecord = {
id: string;
entityId: string | null;
title: string;
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
status: string;
@@ -109,6 +111,7 @@ function densityTone(cell: HeatmapCell) {
function buildFocusRecords(tasks: GanttTaskDto[]) {
return tasks.map((task) => ({
id: task.id,
entityId: task.entityId ?? null,
title: task.text,
kind: parseFocusKind(task),
status: task.status ?? "PLANNED",
@@ -164,16 +167,21 @@ export function WorkbenchPage() {
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
const [rescheduleStart, setRescheduleStart] = useState("");
const [rescheduleStationId, setRescheduleStationId] = useState("");
const [isRescheduling, setIsRescheduling] = useState(false);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
useEffect(() => {
if (!token) {
return;
}
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.then(([data, rollup]) => {
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token), api.getManufacturingStations(token)])
.then(([data, rollup, stationOptions]) => {
setTimeline(data);
setPlanningRollup(rollup);
setStations(stationOptions);
setStatus("Planning workbench loaded.");
})
.catch((error: unknown) => {
@@ -191,6 +199,16 @@ export function WorkbenchPage() {
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
useEffect(() => {
if (selectedFocus?.kind === "OPERATION") {
setRescheduleStart(selectedFocus.start.slice(0, 16));
setRescheduleStationId(selectedFocus.stationId ?? "");
} else {
setRescheduleStart("");
setRescheduleStationId("");
}
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
const heatmap = useMemo(() => {
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
const cells = new Map<string, HeatmapCell>();
@@ -226,6 +244,9 @@ export function WorkbenchPage() {
}, [filteredFocusRecords, summary]);
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null;
const agendaItems = useMemo(
() => [...focusRecords]
.filter((record) => record.kind !== "OPERATION")
@@ -253,15 +274,23 @@ export function WorkbenchPage() {
{ value: "overdue", label: "Overdue" },
];
async function refreshWorkbench(message: string) {
if (!token) {
return;
}
const [refreshed, stationOptions] = await Promise.all([api.getPlanningTimeline(token), api.getManufacturingStations(token)]);
setTimeline(refreshed);
setStations(stationOptions);
setStatus(message);
}
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.");
await refreshWorkbench("Workbench refreshed after release.");
return;
}
if (action.href) {
@@ -269,6 +298,50 @@ export function WorkbenchPage() {
}
}
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
if (!token || !selectedFocus || selectedFocus.kind !== "OPERATION" || !selectedFocus.workOrderId || !selectedFocus.entityId) {
return;
}
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
if (!plannedStart) {
return;
}
setIsRescheduling(true);
try {
await api.updateWorkOrderOperationSchedule(token, selectedFocus.workOrderId, selectedFocus.entityId, {
plannedStart,
stationId: (nextStationId ?? rescheduleStationId) || null,
});
await refreshWorkbench("Workbench refreshed after operation rebalance.");
setRescheduleStart(plannedStart.slice(0, 16));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench.";
setStatus(message);
} finally {
setIsRescheduling(false);
}
}
function shiftRescheduleDraft(hours: number) {
if (!rescheduleStart) {
return;
}
const next = new Date(rescheduleStart);
next.setHours(next.getHours() + hours);
setRescheduleStart(next.toISOString().slice(0, 16));
}
function moveDraftToSelectedHeatmapDay() {
if (!selectedHeatmapDate) {
return;
}
const current = rescheduleStart ? new Date(rescheduleStart) : new Date(`${selectedHeatmapDate}T08:00:00`);
const target = new Date(`${selectedHeatmapDate}T${String(current.getHours()).padStart(2, "0")}:${String(current.getMinutes()).padStart(2, "0")}:00`);
setRescheduleStart(target.toISOString().slice(0, 16));
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -391,6 +464,95 @@ export function WorkbenchPage() {
<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>
{selectedFocus.kind === "OPERATION" ? (
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Workbench Rebalance</div>
<div className="mt-3">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Target Station</label>
<select
value={rescheduleStationId}
onChange={(event) => setRescheduleStationId(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{stations
.filter((station) => station.isActive || station.id === selectedFocus.stationId)
.map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}{station.isActive ? "" : " (Inactive)"}
</option>
))}
</select>
</div>
<div className="mt-3">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Planned Start</label>
<input
type="datetime-local"
value={rescheduleStart}
onChange={(event) => setRescheduleStart(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</div>
{selectedRescheduleStation ? (
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface/80 p-3 text-xs text-muted">
<div className="flex items-center justify-between gap-3">
<span>Capacity</span>
<span className="font-semibold text-text">
{selectedRescheduleStation.dailyCapacityMinutes} min/day x {selectedRescheduleStation.parallelCapacity}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span>Working days</span>
<span className="font-semibold text-text">
{selectedRescheduleStation.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}
</span>
</div>
{selectedRescheduleLoad ? (
<div className="mt-2 flex items-center justify-between gap-3">
<span>Current load</span>
<span className="font-semibold text-text">
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"}
</span>
</div>
) : null}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => shiftRescheduleDraft(1)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1h</button>
<button type="button" onClick={() => shiftRescheduleDraft(8)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 shift</button>
<button type="button" onClick={() => shiftRescheduleDraft(24)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 day</button>
{selectedHeatmapDate ? (
<button type="button" onClick={moveDraftToSelectedHeatmapDay} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">
Move to {formatDate(selectedHeatmapDate)}
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleRescheduleOperation()}
disabled={isRescheduling || !rescheduleStart}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isRescheduling ? "Rebalancing..." : "Apply rebalance"}
</button>
<button
type="button"
onClick={() => {
const originalStationId = selectedFocus.stationId ?? "";
setRescheduleStationId(originalStationId);
void handleRescheduleOperation(new Date(selectedFocus.start).toISOString(), originalStationId);
}}
disabled={isRescheduling}
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
Reset
</button>
</div>
<div className="mt-3 text-xs text-muted">
Rebalance starts from this operation, can move it onto another active station, and rebuilds downstream operations using station calendars and capacity.
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{selectedFocus.actions.map((action, index) => (
<button

View File

@@ -0,0 +1,3 @@
ALTER TABLE "ManufacturingStation" ADD COLUMN "dailyCapacityMinutes" INTEGER NOT NULL DEFAULT 480;
ALTER TABLE "ManufacturingStation" ADD COLUMN "parallelCapacity" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "ManufacturingStation" ADD COLUMN "workingDays" TEXT NOT NULL DEFAULT '1,2,3,4,5';

View File

@@ -648,6 +648,9 @@ model ManufacturingStation {
name String
description String
queueDays Int @default(0)
dailyCapacityMinutes Int @default(480)
parallelCapacity Int @default(1)
workingDays String @default("1,2,3,4,5")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -10,8 +10,6 @@ import type {
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;
@@ -59,7 +57,7 @@ type PlanningWorkOrderRecord = {
plannedStart: Date;
plannedEnd: Date;
plannedMinutes: number;
station: { id: string; code: string; name: string };
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
}>;
materialIssues: Array<{ componentItemId: string; quantity: number }>;
};
@@ -88,6 +86,9 @@ type StationAccumulator = {
readyCount: number;
lateCount: number;
dayKeys: Set<string>;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
};
function clampProgress(value: number) {
@@ -153,6 +154,14 @@ function encodeQuery(params: Record<string, string | null | undefined>) {
return query.length > 0 ? `?${query}` : "";
}
function parseWorkingDays(value: string) {
const parsed = value
.split(",")
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
}
function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED";
}
@@ -166,7 +175,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
}
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
const capacityMinutes = Math.max(record.dayKeys.size, 1) * SHIFT_MINUTES_PER_DAY;
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
return {
stationId: record.stationId,
@@ -299,7 +308,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
plannedStart: true,
plannedEnd: true,
plannedMinutes: true,
station: { select: { id: true, code: true, name: true } },
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
},
orderBy: [{ sequence: "asc" }],
},
@@ -489,6 +498,9 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
readyCount: 0,
lateCount: 0,
dayKeys: new Set<string>(),
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
parallelCapacity: operation.station.parallelCapacity,
workingDays: parseWorkingDays(operation.station.workingDays),
};
current.operationCount += 1;
current.workOrderIds.add(workOrder.id);

View File

@@ -16,6 +16,7 @@ import {
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus,
} from "./service.js";
@@ -24,6 +25,9 @@ const stationSchema = z.object({
name: z.string().trim().min(1).max(160),
description: z.string(),
queueDays: z.number().int().min(0).max(365),
dailyCapacityMinutes: z.number().int().min(60).max(1440),
parallelCapacity: z.number().int().min(1).max(24),
workingDays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
isActive: z.boolean(),
});
@@ -64,6 +68,11 @@ const completionSchema = z.object({
notes: z.string(),
});
const operationScheduleSchema = z.object({
plannedStart: z.string().datetime(),
stationId: z.string().trim().min(1).nullable().optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -166,6 +175,26 @@ manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/schedule", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationScheduleSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation schedule payload is invalid.");
}
const result = await updateWorkOrderOperationSchedule(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {

View File

@@ -7,6 +7,7 @@ import type {
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationDto,
WorkOrderOperationScheduleInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -23,6 +24,9 @@ type StationRecord = {
name: string;
description: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
@@ -55,6 +59,9 @@ type WorkOrderRecord = {
code: string;
name: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
};
}>;
bomLines: Array<{
@@ -106,6 +113,9 @@ type WorkOrderRecord = {
id: string;
code: string;
name: string;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: string;
};
}>;
materialIssues: Array<{
@@ -146,12 +156,16 @@ type WorkOrderRecord = {
};
function mapStation(record: StationRecord): ManufacturingStationDto {
const workingDays = record.workingDays.split(",").map((value) => Number.parseInt(value, 10)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
queueDays: record.queueDays,
dailyCapacityMinutes: record.dailyCapacityMinutes,
parallelCapacity: record.parallelCapacity,
workingDays: workingDays.length > 0 ? workingDays : [1, 2, 3, 4, 5],
isActive: record.isActive,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
@@ -170,6 +184,9 @@ function buildInclude() {
code: true,
name: true,
queueDays: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
},
},
},
@@ -230,6 +247,9 @@ function buildInclude() {
id: true,
code: true,
name: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
},
},
},
@@ -337,6 +357,9 @@ function mapDetail(
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
stationDailyCapacityMinutes: operation.station.dailyCapacityMinutes,
stationParallelCapacity: operation.station.parallelCapacity,
stationWorkingDays: parseWorkingDays(operation.station.workingDays),
sequence: operation.sequence,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
@@ -400,6 +423,68 @@ function addMinutes(value: Date, minutes: number) {
return new Date(value.getTime() + minutes * 60 * 1000);
}
function parseWorkingDays(value: string) {
const parsed = value
.split(",")
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6);
return parsed.length > 0 ? parsed : [1, 2, 3, 4, 5];
}
function normalizeStationWorkingDays(value: number[]) {
const normalized = [...new Set(value.filter((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 6))].sort((left, right) => left - right);
return normalized.length > 0 ? normalized : [1, 2, 3, 4, 5];
}
function alignToWorkingWindow(value: Date, workingDays: number[]) {
let next = new Date(value);
while (!workingDays.includes(next.getDay())) {
next = new Date(next.getFullYear(), next.getMonth(), next.getDate() + 1, 8, 0, 0, 0);
}
return next;
}
function addWorkingMinutes(
start: Date,
minutes: number,
station: { dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string; queueDays: number }
) {
const workingDays = parseWorkingDays(station.workingDays);
const dailyCapacityMinutes = Math.max(station.dailyCapacityMinutes * Math.max(station.parallelCapacity, 1), 60);
let cursor = alignToWorkingWindow(start, workingDays);
let remaining = Math.max(minutes, 0);
while (remaining > 0) {
const dayStart = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), 8, 0, 0, 0);
const dayEnd = addMinutes(dayStart, dailyCapacityMinutes);
const effectiveStart = cursor < dayStart ? dayStart : cursor;
const availableToday = Math.max(Math.round((dayEnd.getTime() - effectiveStart.getTime()) / 60000), 0);
if (availableToday <= 0) {
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
continue;
}
const consumed = Math.min(availableToday, remaining);
cursor = addMinutes(effectiveStart, consumed);
remaining -= consumed;
if (remaining > 0) {
cursor = alignToWorkingWindow(new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1, 8, 0, 0, 0), workingDays);
}
}
if (station.queueDays > 0) {
let queued = new Date(cursor);
for (let day = 0; day < station.queueDays; day += 1) {
queued = alignToWorkingWindow(new Date(queued.getFullYear(), queued.getMonth(), queued.getDate() + 1, queued.getHours(), queued.getMinutes(), 0, 0), workingDays);
}
return queued;
}
return cursor;
}
function shouldReserveForStatus(status: string) {
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
}
@@ -415,7 +500,7 @@ function buildWorkOrderOperationPlan(
}
const operationDurations = itemOperations.map((operation) => {
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes, 1);
return {
stationId: operation.station.id,
sequence: operation.position,
@@ -424,33 +509,17 @@ function buildWorkOrderOperationPlan(
moveMinutes: operation.moveMinutes,
plannedMinutes,
notes: operation.notes,
station: operation.station,
};
});
if (dueDate) {
let nextEnd = new Date(dueDate);
return operationDurations
.slice()
.sort((left, right) => right.sequence - left.sequence)
.map((operation) => {
const plannedStart = addMinutes(nextEnd, -operation.plannedMinutes);
let nextStart = new Date(dueDate ?? fallbackStart);
return operationDurations.map((operation) => {
const plannedStart = alignToWorkingWindow(nextStart, parseWorkingDays(operation.station.workingDays));
const plannedEnd = addWorkingMinutes(plannedStart, operation.plannedMinutes, operation.station);
const planned = {
...operation,
plannedStart,
plannedEnd: nextEnd,
};
nextEnd = plannedStart;
return planned;
})
.reverse();
}
let nextStart = new Date(fallbackStart);
return operationDurations.map((operation) => {
const plannedEnd = addMinutes(nextStart, operation.plannedMinutes);
const planned = {
...operation,
plannedStart: nextStart,
plannedEnd,
};
nextStart = plannedEnd;
@@ -501,6 +570,70 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
});
}
async function rescheduleWorkOrderOperations(workOrderId: string, operationId: string, plannedStart: Date) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return null;
}
const operations = (workOrder as WorkOrderRecord).operations;
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
if (anchorIndex < 0) {
return null;
}
return rescheduleWorkOrderOperationsToStation(workOrderId, operations, anchorIndex, plannedStart, null);
}
async function rescheduleWorkOrderOperationsToStation(
workOrderId: string,
operations: WorkOrderRecord["operations"],
anchorIndex: number,
plannedStart: Date,
targetStation: Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays"> | null
) {
let nextStart = plannedStart;
const updates = operations.slice(anchorIndex).map((operation) => {
const station = operation.id === operations[anchorIndex]?.id && targetStation
? targetStation
: {
id: operation.station.id,
dailyCapacityMinutes: operation.station.dailyCapacityMinutes,
parallelCapacity: operation.station.parallelCapacity,
workingDays: operation.station.workingDays,
queueDays: 0,
};
const alignedStart = alignToWorkingWindow(nextStart, parseWorkingDays(station.workingDays));
const plannedEnd = addWorkingMinutes(alignedStart, operation.plannedMinutes, station);
nextStart = plannedEnd;
return {
id: operation.id,
stationId: station.id,
plannedStart: alignedStart,
plannedEnd,
};
});
await prisma.$transaction(
updates.map((update) =>
prisma.workOrderOperation.update({
where: { id: update.id },
data: {
stationId: update.stationId,
plannedStart: update.plannedStart,
plannedEnd: update.plannedEnd,
},
})
)
);
return getWorkOrderById(workOrderId);
}
async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
@@ -760,12 +893,16 @@ export async function listManufacturingStations(): Promise<ManufacturingStationD
}
export async function createManufacturingStation(payload: ManufacturingStationInput, actorId?: string | null) {
const workingDays = normalizeStationWorkingDays(payload.workingDays);
const station = await prisma.manufacturingStation.create({
data: {
code: payload.code.trim(),
name: payload.name.trim(),
description: payload.description,
queueDays: payload.queueDays,
dailyCapacityMinutes: payload.dailyCapacityMinutes,
parallelCapacity: payload.parallelCapacity,
workingDays: workingDays.join(","),
isActive: payload.isActive,
},
});
@@ -780,6 +917,9 @@ export async function createManufacturingStation(payload: ManufacturingStationIn
code: station.code,
name: station.name,
queueDays: station.queueDays,
dailyCapacityMinutes: station.dailyCapacityMinutes,
parallelCapacity: station.parallelCapacity,
workingDays,
isActive: station.isActive,
},
});
@@ -1017,6 +1157,110 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrderOperationSchedule(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationScheduleInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
stationId: true,
station: {
select: {
code: true,
name: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "COMPLETE" || workOrder.status === "CANCELLED") {
return { ok: false as const, reason: "Completed or cancelled work orders cannot be rescheduled." };
}
let targetStation:
| Pick<StationRecord, "id" | "queueDays" | "dailyCapacityMinutes" | "parallelCapacity" | "workingDays">
| null = null;
if (payload.stationId && payload.stationId !== existing.stationId) {
const station = await prisma.manufacturingStation.findUnique({
where: { id: payload.stationId },
select: {
id: true,
queueDays: true,
dailyCapacityMinutes: true,
parallelCapacity: true,
workingDays: true,
isActive: true,
},
});
if (!station || !station.isActive) {
return { ok: false as const, reason: "Selected manufacturing station was not found or is inactive." };
}
targetStation = station;
}
const workOrderRecord = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrderRecord) {
return { ok: false as const, reason: "Work order was not found." };
}
const operations = (workOrderRecord as WorkOrderRecord).operations;
const anchorIndex = operations.findIndex((operation) => operation.id === operationId);
if (anchorIndex < 0) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
const rescheduled = await rescheduleWorkOrderOperationsToStation(
workOrderId,
operations,
anchorIndex,
new Date(payload.plannedStart),
targetStation
);
if (!rescheduled) {
return { ok: false as const, reason: "Unable to reschedule the requested operation." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operations.rescheduled",
summary: `Rescheduled work order ${rescheduled.workOrderNumber} from operation ${existing.sequence}.`,
metadata: {
workOrderNumber: rescheduled.workOrderNumber,
operationId,
sequence: existing.sequence,
plannedStart: payload.plannedStart,
previousStationId: existing.stationId,
previousStationCode: existing.station.code,
previousStationName: existing.station.name,
stationId: payload.stationId ?? existing.stationId,
},
});
return { ok: true as const, workOrder: rescheduled };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },

View File

@@ -8,6 +8,9 @@ export interface ManufacturingStationDto {
name: string;
description: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -18,6 +21,9 @@ export interface ManufacturingStationInput {
name: string;
description: string;
queueDays: number;
dailyCapacityMinutes: number;
parallelCapacity: number;
workingDays: number[];
isActive: boolean;
}
@@ -71,6 +77,9 @@ export interface WorkOrderOperationDto {
stationId: string;
stationCode: string;
stationName: string;
stationDailyCapacityMinutes: number;
stationParallelCapacity: number;
stationWorkingDays: number[];
sequence: number;
setupMinutes: number;
runMinutesPerUnit: number;
@@ -159,3 +168,8 @@ export interface WorkOrderCompletionInput {
quantity: number;
notes: string;
}
export interface WorkOrderOperationScheduleInput {
plannedStart: string;
stationId?: string | null;
}