diff --git a/CHANGELOG.md b/CHANGELOG.md index 7235e91..1d8473e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh - 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 +- Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen - 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 +- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form diff --git a/README.md b/README.md index ca97f45..7b30666 100644 --- a/README.md +++ b/README.md @@ -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, 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 +- manufacturing work orders with project linkage, station-based operation templates, editable 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, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling - 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, station calendars and capacity settings, automatic work-order operation plans, operation-level rescheduling, 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, editable 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: @@ -126,7 +126,7 @@ 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, 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. +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, station-lane drag scheduling with projected load cues, inline release/build/buy follow-through, and agenda sequencing. Current interactions: diff --git a/ROADMAP.md b/ROADMAP.md index 5a3f162..387812d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,7 +106,7 @@ This file tracks work that still needs to be completed. Shipped phase history an - 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 -- Conflict-aware drag-and-drop rescheduling improvements beyond the shipped planner-side station reassignment controls +- Richer conflict handling, queue-slot suggestions, and auto-rebalance logic beyond the shipped station-lane drag scheduling - Critical-path and overdue highlighting - 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 diff --git a/SHIPPED.md b/SHIPPED.md index 5aa0e38..69545d4 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -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, station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule +- Manufacturing stations, item routing templates, editable 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 @@ -57,7 +57,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - 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 +- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, and station-lane drag scheduling - 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 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index aa0100b..2e37350 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -612,6 +612,9 @@ export const api = { createManufacturingStation(token: string, payload: ManufacturingStationInput) { return request("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token); }, + updateManufacturingStation(token: string, stationId: string, payload: ManufacturingStationInput) { + return request(`/api/v1/manufacturing/stations/${stationId}`, { method: "PUT", body: JSON.stringify(payload) }, token); + }, getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) { return request( `/api/v1/manufacturing/work-orders${buildQueryString({ diff --git a/client/src/modules/manufacturing/ManufacturingPage.tsx b/client/src/modules/manufacturing/ManufacturingPage.tsx index 859c9d9..5c9c0a4 100644 --- a/client/src/modules/manufacturing/ManufacturingPage.tsx +++ b/client/src/modules/manufacturing/ManufacturingPage.tsx @@ -20,6 +20,7 @@ export function ManufacturingPage() { const { token, user } = useAuth(); const [stations, setStations] = useState([]); const [form, setForm] = useState(emptyStationInput); + const [editingStationId, setEditingStationId] = useState(null); const [status, setStatus] = useState("Define manufacturing stations once so routings and work orders can schedule automatically."); const [isSaving, setIsSaving] = useState(false); const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false; @@ -32,6 +33,27 @@ export function ManufacturingPage() { api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); }, [token]); + function resetForm(nextStatus = "Define manufacturing stations once so routings and work orders can schedule automatically.") { + setForm(emptyStationInput); + setEditingStationId(null); + setStatus(nextStatus); + } + + function startEditing(station: ManufacturingStationDto) { + setEditingStationId(station.id); + setForm({ + code: station.code, + name: station.name, + description: station.description, + queueDays: station.queueDays, + dailyCapacityMinutes: station.dailyCapacityMinutes, + parallelCapacity: station.parallelCapacity, + workingDays: station.workingDays, + isActive: station.isActive, + }); + setStatus(`Editing station ${station.code}.`); + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (!token) { @@ -39,12 +61,15 @@ export function ManufacturingPage() { } setIsSaving(true); - setStatus("Saving station..."); + setStatus(editingStationId ? "Updating station..." : "Saving station..."); try { - const station = await api.createManufacturingStation(token, form); - setStations((current) => [...current, station].sort((left, right) => left.code.localeCompare(right.code))); - setForm(emptyStationInput); - setStatus("Station saved."); + const station = editingStationId + ? await api.updateManufacturingStation(token, editingStationId, form) + : await api.createManufacturingStation(token, form); + setStations((current) => + (editingStationId ? current.map((entry) => (entry.id === station.id ? station : entry)) : [...current, station]).sort((left, right) => left.code.localeCompare(right.code)) + ); + resetForm(editingStationId ? "Station updated." : "Station saved."); } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to save station."; setStatus(message); @@ -72,6 +97,11 @@ export function ManufacturingPage() {
{station.code} - {station.name}
{station.description || "No description"}
+ {canManage ? ( + + ) : null}
{station.queueDays} expected wait day(s)
@@ -87,7 +117,7 @@ export function ManufacturingPage() { {canManage ? (
-

New Station

+

{editingStationId ? "Edit Station" : "New Station"}

{status} - +
+ + {editingStationId ? ( + + ) : null} +
diff --git a/client/src/modules/workbench/WorkbenchPage.tsx b/client/src/modules/workbench/WorkbenchPage.tsx index 60ea614..43978d3 100644 --- a/client/src/modules/workbench/WorkbenchPage.tsx +++ b/client/src/modules/workbench/WorkbenchPage.tsx @@ -41,6 +41,7 @@ type FocusRecord = { overdue: boolean; blockedReason: string | null; utilizationPercent: number | null; + loadMinutes: number; actions: PlanningTaskActionDto[]; }; @@ -54,6 +55,13 @@ type HeatmapCell = { type WorkbenchGroup = "projects" | "stations" | "exceptions"; type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue"; +type DraggingOperation = { + id: string; + title: string; + stationId: string | null; + start: string; + loadMinutes: number; +}; const DAY_MS = 24 * 60 * 60 * 1000; @@ -137,6 +145,7 @@ function buildFocusRecords(tasks: GanttTaskDto[]) { overdue: task.overdue ?? false, blockedReason: task.blockedReason ?? null, utilizationPercent: task.utilizationPercent ?? null, + loadMinutes: task.loadMinutes ?? 0, actions: task.actions ?? [], })); } @@ -171,6 +180,8 @@ export function WorkbenchPage() { const [rescheduleStationId, setRescheduleStationId] = useState(""); const [isRescheduling, setIsRescheduling] = useState(false); const [stations, setStations] = useState([]); + const [draggingOperation, setDraggingOperation] = useState(null); + const [dropStationId, setDropStationId] = useState(null); useEffect(() => { if (!token) { @@ -298,32 +309,46 @@ export function WorkbenchPage() { } } - async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) { - if (!token || !selectedFocus || selectedFocus.kind !== "OPERATION" || !selectedFocus.workOrderId || !selectedFocus.entityId) { + async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) { + if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) { return; } - const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : ""); + const plannedStart = nextStartIso ?? new Date(record.start).toISOString(); if (!plannedStart) { return; } setIsRescheduling(true); try { - await api.updateWorkOrderOperationSchedule(token, selectedFocus.workOrderId, selectedFocus.entityId, { + await api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, { plannedStart, - stationId: (nextStationId ?? rescheduleStationId) || null, + stationId: nextStationId ?? record.stationId ?? null, }); await refreshWorkbench("Workbench refreshed after operation rebalance."); + setSelectedFocusId(record.id); setRescheduleStart(plannedStart.slice(0, 16)); + if (nextStationId) { + setRescheduleStationId(nextStationId); + } } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench."; setStatus(message); } finally { setIsRescheduling(false); + setDraggingOperation(null); + setDropStationId(null); } } + async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) { + if (!selectedFocus || selectedFocus.kind !== "OPERATION") { + return; + } + const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : ""); + await rebalanceOperation(selectedFocus, plannedStart, (nextStationId ?? rescheduleStationId) || null); + } + function shiftRescheduleDraft(hours: number) { if (!rescheduleStart) { return; @@ -342,6 +367,23 @@ export function WorkbenchPage() { setRescheduleStart(target.toISOString().slice(0, 16)); } + async function handleStationDrop(targetStationId: string) { + if (!draggingOperation) { + return; + } + const record = focusById.get(draggingOperation.id); + if (!record || record.kind !== "OPERATION") { + setDraggingOperation(null); + setDropStationId(null); + return; + } + + const plannedStart = selectedHeatmapDate + ? new Date(`${selectedHeatmapDate}T${new Date(record.start).toTimeString().slice(0, 5)}:00`).toISOString() + : new Date(record.start).toISOString(); + await rebalanceOperation(record, plannedStart, targetStationId); + } + return (
@@ -440,7 +482,20 @@ export function WorkbenchPage() {
- {workbenchMode === "overview" ? : null} + {workbenchMode === "overview" ? ( + + ) : null} {workbenchMode === "heatmap" ? : null} {workbenchMode === "agenda" ? : null}
@@ -597,18 +652,42 @@ function OverviewBoard({ stationLoads, groupMode, onSelect, + draggingOperation, + dropStationId, + selectedHeatmapDate, + onDragOperation, + onDropStation, + onDropStationChange, }: { focusRecords: FocusRecord[]; stationLoads: PlanningStationLoadDto[]; groupMode: WorkbenchGroup; onSelect: (id: string) => void; + draggingOperation: DraggingOperation | null; + dropStationId: string | null; + selectedHeatmapDate: string | null; + onDragOperation: (operation: DraggingOperation | null) => void; + onDropStation: (stationId: string) => void | Promise; + onDropStationChange: (stationId: string | null) => void; }) { const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6); - const operations = focusRecords.filter((record) => record.kind === "OPERATION").slice(0, 10); + const operations = focusRecords.filter((record) => record.kind === "OPERATION"); 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); + const stationOperations = new Map(); + for (const operation of operations) { + if (!operation.stationId) { + continue; + } + const bucket = stationOperations.get(operation.stationId) ?? []; + bucket.push(operation); + stationOperations.set(operation.stationId, bucket); + } + for (const bucket of stationOperations.values()) { + bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime()); + } return (
@@ -642,7 +721,7 @@ function OverviewBoard({

Operation Load

- {operations.map((record) => ( + {operations.slice(0, 10).map((record) => ( +
+ ))} + {(stationOperations.get(station.stationId)?.length ?? 0) > 5 ? ( +
+{(stationOperations.get(station.stationId)?.length ?? 0) - 5} more operations
+ ) : null} +
))}
diff --git a/server/src/modules/manufacturing/router.ts b/server/src/modules/manufacturing/router.ts index 0da4849..4639b0a 100644 --- a/server/src/modules/manufacturing/router.ts +++ b/server/src/modules/manufacturing/router.ts @@ -15,6 +15,7 @@ import { listManufacturingStations, listWorkOrders, recordWorkOrderCompletion, + updateManufacturingStation, updateWorkOrder, updateWorkOrderOperationSchedule, updateWorkOrderStatus, @@ -100,6 +101,25 @@ manufacturingRouter.post("/stations", requirePermissions([permissions.manufactur return ok(response, await createManufacturingStation(parsed.data, request.authUser?.id), 201); }); +manufacturingRouter.put("/stations/:stationId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => { + const stationId = getRouteParam(request.params.stationId); + if (!stationId) { + return fail(response, 400, "INVALID_INPUT", "Manufacturing station id is invalid."); + } + + const parsed = stationSchema.safeParse(request.body); + if (!parsed.success) { + return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid."); + } + + const result = await updateManufacturingStation(stationId, parsed.data, request.authUser?.id); + if (!result.ok) { + return fail(response, 404, "STATION_NOT_FOUND", result.reason); + } + + return ok(response, result.station); +}); + manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => { const parsed = workOrderFiltersSchema.safeParse(request.query); if (!parsed.success) { diff --git a/server/src/modules/manufacturing/service.ts b/server/src/modules/manufacturing/service.ts index 5769536..fb3b582 100644 --- a/server/src/modules/manufacturing/service.ts +++ b/server/src/modules/manufacturing/service.ts @@ -927,6 +927,57 @@ export async function createManufacturingStation(payload: ManufacturingStationIn return mapStation(station); } +export async function updateManufacturingStation(stationId: string, payload: ManufacturingStationInput, actorId?: string | null) { + const existing = await prisma.manufacturingStation.findUnique({ + where: { id: stationId }, + }); + + if (!existing) { + return { ok: false as const, reason: "Manufacturing station was not found." }; + } + + const workingDays = normalizeStationWorkingDays(payload.workingDays); + const station = await prisma.manufacturingStation.update({ + where: { id: stationId }, + 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, + }, + }); + + await logAuditEvent({ + actorId, + entityType: "manufacturing-station", + entityId: station.id, + action: "updated", + summary: `Updated manufacturing station ${station.code}.`, + metadata: { + previousCode: existing.code, + previousName: existing.name, + previousQueueDays: existing.queueDays, + previousDailyCapacityMinutes: existing.dailyCapacityMinutes, + previousParallelCapacity: existing.parallelCapacity, + previousWorkingDays: parseWorkingDays(existing.workingDays), + previousIsActive: existing.isActive, + code: station.code, + name: station.name, + queueDays: station.queueDays, + dailyCapacityMinutes: station.dailyCapacityMinutes, + parallelCapacity: station.parallelCapacity, + workingDays, + isActive: station.isActive, + }, + }); + + return { ok: true as const, station: mapStation(station) }; +} + export async function listManufacturingProjectOptions(): Promise { const projects = await prisma.project.findMany({ where: {