manufacturing and gantt

This commit is contained in:
2026-03-15 12:11:46 -05:00
parent a9d31730f8
commit 16582d3cea
26 changed files with 1614 additions and 75 deletions

View File

@@ -20,7 +20,8 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and attachments
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
- planning gantt timelines backed by live project and manufacturing schedule data
- Puppeteer PDF foundation
- single-container Docker deployment
@@ -117,10 +118,9 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
1. Planning and gantt scheduling with live project/manufacturing data
2. Inventory transfers, reservations, and deeper stock controls
3. Broader audit-trail coverage and operational diagnostics
4. Code-splitting and bundle-size reduction
1. Inventory transfers, reservations, and deeper stock controls
2. Broader audit-trail coverage and operational diagnostics
3. Code-splitting and bundle-size reduction
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added
- Manufacturing stations with queue-day definitions and item-level station/time operation templates
- Automatic work-order operation plans copied from buildable item routing into planning/gantt
- Live planning gantt timelines backed by active projects and open manufacturing work orders
- Planning summary metrics and exception cards for overdue or at-risk project/manufacturing schedule items
- Sales approval actions with approved-by/approved-at stamps on quotes and sales orders
- Automatic sales-document revision history with authored reasons and per-revision snapshots
- Projects domain foundation with customer, owner, due date, priority, notes, and attachment support
@@ -22,12 +26,14 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page
- Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically
- Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat planning and gantt scheduling as the next active priority after the sales approval and revision-history slice
- Roadmap and project docs now treat inventory transfers and deeper stock controls as the next active priority after the planning slice
## 2026-03-15

View File

@@ -24,7 +24,8 @@ This repository implements the platform foundation milestone:
- purchase-order supporting documents and vendor-side purchasing visibility
- 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, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, material issue posting, completion posting, attachments, and dashboard visibility
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
- planning gantt timelines backed by live project and manufacturing schedule data
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -60,7 +61,6 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- planning and gantt scheduling with live project/manufacturing data
- inventory transfers, reservations, and deeper stock controls
- broader audit and operations maturity
- code-splitting and bundle-size reduction

View File

@@ -23,7 +23,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, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and work-order attachments
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data
- file storage and PDF rendering
## Product Map
@@ -37,18 +38,14 @@ Current completed foundation areas:
- shipping foundation
- projects foundation
- manufacturing foundation
- planning foundation
- branding, attachments, auth/RBAC, and PDF infrastructure
Planned cross-module execution areas:
- planning and gantt scheduling
Near-term priorities:
1. Planning and gantt scheduling with live project/manufacturing data
2. Inventory transfers, reservations, and deeper stock controls
3. Broader audit-trail coverage and operational diagnostics
4. Code-splitting and bundle-size reduction
1. Inventory transfers, reservations, and deeper stock controls
2. Broader audit-trail coverage and operational diagnostics
3. Code-splitting and bundle-size reduction
Revisit / deferred items:
@@ -66,6 +63,7 @@ Dashboard direction:
- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign
- projects now feed dashboard widgets for active programs, overdue work, and risk
- manufacturing now feeds dashboard widgets for released work, overdue orders, and execution load
- planning now feeds live gantt scheduling from project and manufacturing records
- future project widgets should deepen milestones, shortages, and shipment readiness
Navigation direction:
@@ -94,7 +92,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, 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, automatic work-order operation plans, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Current interactions:
@@ -108,6 +106,22 @@ Next expansion areas:
- Shipping: completed manufacturing should feed shipment readiness
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
## 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 gantt surface backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, and exception cards for overdue or at-risk schedule items.
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
- Dashboard: planning now appears as a first-class module with schedule visibility links
Next expansion areas:
- Purchasing: shortages, late receipts, and vendor risk should surface directly in planning
- Manufacturing: routings, work centers, and capacity should deepen the schedule model
- Projects: richer milestones and dependency editing should extend the project-level timeline
## Workspace
- `client`: React, Vite, Tailwind frontend
@@ -318,6 +332,7 @@ As of March 14, 2026, the latest committed domain migrations include:
- shipping foundation
- projects foundation
- manufacturing foundation
- planning foundation
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.

View File

@@ -43,12 +43,13 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- 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 gantt scheduling
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- SVAR Gantt integration wrapper with demo planning data
- Live planning gantt timelines driven by project and manufacturing data
- Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
@@ -222,6 +223,12 @@ QOL subfeatures:
### Phase 7: Planning and scheduling
Foundation slice shipped:
- Live gantt 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
- Live project-backed SVAR gantt timelines
- Task dependencies, milestones, and progress updates
- Manufacturing calendar views and bottleneck visibility
@@ -275,7 +282,6 @@ QOL subfeatures:
## Near-term priority order
1. Planning and scheduling with live project/manufacturing data
2. Inventory transfers, reservations, and deeper stock controls
3. Broader audit-trail coverage and operational diagnostics
4. Code-splitting and bundle-size reduction
1. Inventory transfers, reservations, and deeper stock controls
2. Broader audit-trail coverage and operational diagnostics
3. Code-splitting and bundle-size reduction

View File

@@ -3,8 +3,7 @@ import type {
CompanyProfileDto,
CompanyProfileInput,
FileAttachmentDto,
GanttLinkDto,
GanttTaskDto,
PlanningTimelineDto,
LoginRequest,
LoginResponse,
} from "@mrp/shared";
@@ -34,6 +33,8 @@ import type {
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
ManufacturingStationDto,
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
@@ -461,6 +462,12 @@ export const api = {
getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
},
getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
},
createManufacturingStation(token: string, payload: ManufacturingStationInput) {
return request<ManufacturingStationDto>("/api/v1/manufacturing/stations", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({
@@ -503,8 +510,8 @@ export const api = {
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
getPlanningTimeline(token: string) {
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
},
getSalesCustomers(token: string) {
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);

View File

@@ -55,6 +55,7 @@ export function DashboardPage() {
const [error, setError] = useState<string | null>(null);
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead);
useEffect(() => {
if (!token || !user) {
@@ -150,6 +151,7 @@ export function DashboardPage() {
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
snapshot?.projects !== null,
canReadPlanning,
].filter(Boolean).length;
const customerCount = customers.length;
@@ -398,12 +400,24 @@ export function DashboardPage() {
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
],
},
{
title: "Planning",
eyebrow: "Schedule Visibility",
summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view."
: "Planning read permission is required to surface the live gantt schedule.",
metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" },
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" },
],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
},
];
const futureModules = [
"Stock transfers, allocations, and cycle counts",
"Planning timeline, milestones, and dependency views",
"Sales approvals, revisions, and change history",
"Revision comparison and document restore tooling",
"Audit trails, diagnostics, and system health checks",
];
@@ -461,6 +475,11 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
Open manufacturing
</Link>
{canReadPlanning ? (
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/planning/gantt">
Open planning
</Link>
) : null}
</div>
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
</div>

View File

@@ -1,48 +1,166 @@
import { useEffect, useState } from "react";
import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css";
import { Link } from "react-router-dom";
import type { GanttLinkDto, GanttTaskDto } from "@mrp/shared";
import type { GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider";
import { api } from "../../lib/api";
import { ApiError, api } from "../../lib/api";
import { useTheme } from "../../theme/ThemeProvider";
function formatDate(value: string | null) {
if (!value) {
return "Unscheduled";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(new Date(value));
}
export function GanttPage() {
const { token } = useAuth();
const { mode } = useTheme();
const [tasks, setTasks] = useState<GanttTaskDto[]>([]);
const [links, setLinks] = useState<GanttLinkDto[]>([]);
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => {
if (!token) {
return;
}
api.getGanttDemo(token).then((data) => {
setTasks(data.tasks);
setLinks(data.links);
});
api
.getPlanningTimeline(token)
.then((data) => {
setTimeline(data);
setStatus("Planning timeline loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load planning timeline.";
setStatus(message);
});
}, [token]);
const tasks = timeline?.tasks ?? [];
const links = timeline?.links ?? [];
const summary = timeline?.summary;
const exceptions = timeline?.exceptions ?? [];
return (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
<h3 className="mt-3 text-2xl font-bold text-text">SVAR Gantt Preview</h3>
<p className="mt-2 text-sm text-muted">Theme-aware integration wrapper prepared for future manufacturing schedules and task dependencies.</p>
<div
className={`gantt-theme mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4 ${
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
}`}
>
<Gantt
tasks={tasks.map((task) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
}))}
links={links}
/>
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
The planning surface now reads directly from active projects and open work orders so schedule pressure, due-date risk, and standalone manufacturing load are visible in one place.
</p>
</div>
<div className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
<div className="mt-2 font-semibold text-text">{status}</div>
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-6">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">At Risk</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Projects</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div
className={`gantt-theme overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
<p className="mt-2 text-sm text-muted">
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
</p>
</div>
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
{tasks.length} schedule rows
</div>
</div>
<Gantt
tasks={tasks.map((task: GanttTaskDto) => ({
...task,
start: new Date(task.start),
end: new Date(task.end),
parent: task.parentId ?? undefined,
}))}
links={links}
/>
</div>
<aside className="space-y-3">
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning Exceptions</p>
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
{exceptions.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No planning exceptions are active.
</div>
) : (
<div className="mt-5 space-y-3">
{exceptions.map((exception: PlanningExceptionDto) => (
<Link key={exception.id} to={exception.detailHref} className="block rounded-3xl border border-line/70 bg-page/60 p-3 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>
<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>
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
</Link>
))}
</div>
)}
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
<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>
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open work orders
</Link>
<Link to="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to dashboard
</Link>
</div>
</section>
</aside>
</div>
</section>
);

View File

@@ -122,7 +122,7 @@ export function InventoryDetailPage() {
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<section className="grid gap-3 xl:grid-cols-5">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
@@ -139,6 +139,10 @@ export function InventoryDetailPage() {
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">BOM Lines</p>
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p>
<div className="mt-2 text-base font-bold text-text">{item.operations.length}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -235,6 +239,47 @@ export function InventoryDetailPage() {
</div>
)}
</section>
{(item.type === "ASSEMBLY" || item.type === "MANUFACTURED") ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station template</h4>
{item.operations.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No station operations are defined for this buildable item yet.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Position</th>
<th className="px-2 py-2">Station</th>
<th className="px-2 py-2">Setup</th>
<th className="px-2 py-2">Run / Unit</th>
<th className="px-2 py-2">Move</th>
<th className="px-2 py-2">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{item.operations.map((operation) => (
<tr key={operation.id}>
<td className="px-2 py-2 text-muted">{operation.position}</td>
<td className="px-2 py-2">
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-2 py-2 text-muted">{operation.setupMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.runMinutesPerUnit} min</td>
<td className="px-2 py-2 text-muted">{operation.moveMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.notes || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
) : null}
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canManage ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">

View File

@@ -1,10 +1,11 @@
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyInventoryBomLineInput, emptyInventoryItemInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryTypeOptions, inventoryUnitOptions } from "./config";
interface InventoryFormPageProps {
mode: "create" | "edit";
@@ -16,6 +17,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
@@ -80,6 +82,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
notes: line.notes,
position: line.position,
})),
operations: item.operations.map((operation) => ({
stationId: operation.stationId,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
position: operation.position,
notes: operation.notes,
})),
});
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded.");
@@ -90,6 +100,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
});
}, [itemId, mode, token]);
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
@@ -123,6 +141,33 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
setComponentSearchTerms((current) => [...current, ""]);
}
function updateOperation(index: number, nextOperation: InventoryItemOperationInput) {
setForm((current) => ({
...current,
operations: current.operations.map((operation, operationIndex) => (operationIndex === index ? nextOperation : operation)),
}));
}
function addOperation() {
setForm((current) => ({
...current,
operations: [
...current.operations,
{
...emptyInventoryOperationInput,
position: current.operations.length === 0 ? 10 : Math.max(...current.operations.map((operation) => operation.position)) + 10,
},
],
}));
}
function removeOperation(index: number) {
setForm((current) => ({
...current,
operations: current.operations.filter((_operation, operationIndex) => operationIndex !== index),
}));
}
function removeBomLine(index: number) {
setForm((current) => ({
...current,
@@ -289,6 +334,74 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
/>
</label>
</section>
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station and time template</h4>
<p className="mt-2 text-sm text-muted">These operations are copied automatically into work orders and drive gantt scheduling without manual planner task entry.</p>
</div>
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add operation
</button>
</div>
{form.operations.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
Add at least one station operation for this buildable item.
</div>
) : (
<div className="mt-5 space-y-4">
{form.operations.map((operation, index) => (
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span>
<select
value={operation.stationId}
onChange={(event) => updateOperation(index, { ...operation, stationId: 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"
>
<option value="">Select station</option>
{stations.filter((station) => station.isActive).map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Setup</span>
<input type="number" min={0} step={1} value={operation.setupMinutes} onChange={(event) => updateOperation(index, { ...operation, setupMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Run / Unit</span>
<input type="number" min={0} step={1} value={operation.runMinutesPerUnit} onChange={(event) => updateOperation(index, { ...operation, runMinutesPerUnit: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Move</span>
<input type="number" min={0} step={1} value={operation.moveMinutes} onChange={(event) => updateOperation(index, { ...operation, moveMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Position</span>
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
<button type="button" onClick={() => removeOperation(index)} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
Remove
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input value={operation.notes} onChange={(event) => updateOperation(index, { ...operation, notes: 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" />
</label>
</div>
))}
</div>
)}
</section>
) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>

View File

@@ -5,6 +5,7 @@ import {
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
type InventoryItemOperationInput,
type WarehouseInput,
type WarehouseLocationInput,
type InventoryItemStatus,
@@ -22,6 +23,15 @@ export const emptyInventoryBomLineInput: InventoryBomLineInput = {
position: 10,
};
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
stationId: "",
setupMinutes: 0,
runMinutesPerUnit: 0,
moveMinutes: 0,
position: 10,
notes: "",
};
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
name: "",
@@ -35,6 +45,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
defaultPrice: null,
notes: "",
bomLines: [],
operations: [],
};
export const emptyInventoryTransactionInput: InventoryTransactionInput = {

View File

@@ -1,5 +1,120 @@
import { permissions, type ManufacturingStationInput, type ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { WorkOrderListPage } from "./WorkOrderListPage";
const emptyStationInput: ManufacturingStationInput = {
code: "",
name: "",
description: "",
queueDays: 0,
isActive: true,
};
export function ManufacturingPage() {
return <WorkOrderListPage />;
const { token, user } = useAuth();
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [form, setForm] = useState<ManufacturingStationInput>(emptyStationInput);
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;
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
}, [token]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("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.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save station.";
setStatus(message);
} finally {
setIsSaving(false);
}
}
return (
<div className="space-y-4">
<section className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_400px]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing Stations</p>
<h3 className="mt-2 text-xl font-bold text-text">Scheduling anchors</h3>
<p className="mt-2 text-sm text-muted">Stations define where operation time belongs. Buildable items reference them in their routing template, and work orders inherit those steps automatically into planning.</p>
{stations.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No stations defined yet.
</div>
) : (
<div className="mt-5 space-y-3">
{stations.map((station) => (
<article key={station.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{station.code} - {station.name}</div>
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
</div>
<div className="text-right text-xs text-muted">
<div>{station.queueDays} queue day(s)</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
{canManage ? (
<form onSubmit={handleSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">New Station</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Code</span>
<input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value }))} 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">Name</span>
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} 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">Queue 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>
<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-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((current) => ({ ...current, isActive: event.target.checked }))} />
<span className="text-sm font-semibold text-text">Active station</span>
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{status}</span>
<button type="submit" disabled={isSaving} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSaving ? "Saving..." : "Create station"}
</button>
</div>
</div>
</form>
) : null}
</section>
<WorkOrderListPage />
</div>
);
}

View File

@@ -163,11 +163,12 @@ export function WorkOrderDetailPage() {
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-6">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
@@ -185,6 +186,40 @@ export function WorkOrderDetailPage() {
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
</article>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Operation Plan</p>
{workOrder.operations.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">This work order has no inherited station operations. Add routing steps on the item record to automate planning.</div>
) : (
<div className="mt-5 overflow-hidden rounded-3xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/70">
<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">Start</th>
<th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrder.operations.map((operation) => (
<tr key={operation.id} className="bg-surface/70">
<td className="px-3 py-3 text-text">{operation.sequence}</td>
<td className="px-3 py-3">
<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-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>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form onSubmit={handleIssueSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">

View File

@@ -168,7 +168,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setItemPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type} · {option.operationCount} ops</div>
</button>
))}
</div>

View File

@@ -78,6 +78,7 @@ export function WorkOrderListPage() {
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Location</th>
<th className="px-3 py-3">Ops</th>
<th className="px-3 py-3">Due</th>
</tr>
</thead>
@@ -95,6 +96,7 @@ export function WorkOrderListPage() {
<td className="px-3 py-3 align-top"><WorkOrderStatusBadge status={workOrder.status} /></td>
<td className="px-3 py-3 align-top text-text">{workOrder.completedQuantity} / {workOrder.quantity}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.operationCount} / {Math.round(workOrder.totalPlannedMinutes / 60)}h</td>
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}

View File

@@ -0,0 +1,49 @@
CREATE TABLE "ManufacturingStation" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"queueDays" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "InventoryItemOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryItemOperation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryItemOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE "WorkOrderOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"sequence" INTEGER NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedStart" DATETIME NOT NULL,
"plannedEnd" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "ManufacturingStation_code_key" ON "ManufacturingStation"("code");
CREATE INDEX "InventoryItemOperation_itemId_position_idx" ON "InventoryItemOperation"("itemId", "position");
CREATE INDEX "InventoryItemOperation_stationId_idx" ON "InventoryItemOperation"("stationId");
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");

View File

@@ -133,6 +133,7 @@ model InventoryItem {
purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
operations InventoryItemOperation[]
}
model Warehouse {
@@ -476,6 +477,7 @@ model WorkOrder {
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
operations WorkOrderOperation[]
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
@@ -485,6 +487,58 @@ model WorkOrder {
@@index([warehouseId, createdAt])
}
model ManufacturingStation {
id String @id @default(cuid())
code String @unique
name String
description String
queueDays Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
itemOperations InventoryItemOperation[]
workOrderOperations WorkOrderOperation[]
}
model InventoryItemOperation {
id String @id @default(cuid())
itemId String
stationId String
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
moveMinutes Int @default(0)
notes String
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
@@index([itemId, position])
@@index([stationId])
}
model WorkOrderOperation {
id String @id @default(cuid())
workOrderId String
stationId String
sequence Int
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
moveMinutes Int @default(0)
plannedMinutes Int @default(0)
plannedStart DateTime
plannedEnd DateTime
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
@@index([workOrderId, sequence])
@@index([stationId, plannedStart])
}
model WorkOrderMaterialIssue {
id String @id @default(cuid())
workOrderId String

View File

@@ -3,21 +3,10 @@ import { Router } from "express";
import { ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { getPlanningTimeline } from "./service.js";
export const ganttRouter = Router();
ganttRouter.get("/demo", requirePermissions([permissions.ganttRead]), (_request, response) => {
return ok(response, {
tasks: [
{ id: "project-1", text: "Machine Assembly Program", start: "2026-03-16", end: "2026-03-28", progress: 35, type: "project" },
{ id: "task-1", text: "Frame fabrication", start: "2026-03-16", end: "2026-03-19", progress: 80, type: "task" },
{ id: "task-2", text: "Electrical install", start: "2026-03-20", end: "2026-03-25", progress: 20, type: "task" },
{ id: "milestone-1", text: "Factory acceptance", start: "2026-03-28", end: "2026-03-28", progress: 0, type: "milestone" }
],
links: [
{ id: "link-1", source: "task-1", target: "task-2", type: "e2e" },
{ id: "link-2", source: "task-2", target: "milestone-1", type: "e2e" }
],
});
ganttRouter.get("/timeline", requirePermissions([permissions.ganttRead]), async (_request, response) => {
return ok(response, await getPlanningTimeline());
});

View File

@@ -0,0 +1,460 @@
import type { GanttLinkDto, GanttTaskDto, PlanningTimelineDto } from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const DAY_MS = 24 * 60 * 60 * 1000;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function addDays(value: Date, days: number) {
return new Date(value.getTime() + days * DAY_MS);
}
function startOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function endOfDay(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999);
}
function projectProgressFromStatus(status: string) {
switch (status) {
case "COMPLETE":
return 100;
case "AT_RISK":
return 45;
case "ACTIVE":
return 60;
case "ON_HOLD":
return 20;
default:
return 10;
}
}
function workOrderProgress(quantity: number, completedQuantity: number, status: string) {
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 ?? null;
}
export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
const now = new Date();
const planningProjects = await prisma.project.findMany({
where: {
status: {
not: "COMPLETE",
},
},
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: [{ sequence: "asc" }],
},
item: {
select: {
sku: true,
name: 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 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}`,
});
if (project.dueDate) {
tasks.push({
id: `project-milestone-${project.id}`,
text: `${project.projectNumber} due`,
start: startOfDay(project.dueDate).toISOString(),
end: startOfDay(project.dueDate).toISOString(),
progress: project.status === "COMPLETE" ? 100 : 0,
type: "milestone",
parentId: `project-${project.id}`,
status: project.status,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
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));
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
start: workOrderStart.toISOString(),
end: workOrderEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: `project-${project.id}`,
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
if (previousTaskId) {
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",
});
}
previousTaskId = workOrderTaskId;
let previousOperationTaskId: string | null = null;
for (const operation of workOrder.operations) {
const operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
text: `${operation.station.code} - ${operation.station.name}`,
start: operation.plannedStart.toISOString(),
end: operation.plannedEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
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}`,
source: previousOperationTaskId,
target: operationTaskId,
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate && 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(),
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") {
exceptions.push({
id: `project-${project.id}`,
kind: "PROJECT",
title: `${project.projectNumber} - ${project.name}`,
status: project.status,
dueDate: project.dueDate ? project.dueDate.toISOString() : null,
ownerLabel,
detailHref: `/projects/${project.id}`,
});
}
}
if (standaloneWorkOrders.length > 0) {
const firstStandaloneWorkOrder = standaloneWorkOrders[0]!;
const bucketStart = startOfDay(
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)
)
);
tasks.push({
id: "standalone-manufacturing",
text: "Standalone Manufacturing Queue",
start: bucketStart.toISOString(),
end: bucketEnd.toISOString(),
progress: clampProgress(
standaloneWorkOrders.reduce((sum, workOrder) => sum + workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status), 0) /
standaloneWorkOrders.length
),
type: "project",
status: "ACTIVE",
ownerLabel: "Manufacturing",
detailHref: "/manufacturing/work-orders",
});
let previousStandaloneTaskId: string | null = null;
for (const workOrder of standaloneWorkOrders) {
const workOrderTaskId = `work-order-${workOrder.id}`;
tasks.push({
id: workOrderTaskId,
text: `${workOrder.workOrderNumber} - ${workOrder.item.sku}`,
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: "standalone-manufacturing",
status: workOrder.status,
ownerLabel: workOrder.item.name,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
if (previousStandaloneTaskId) {
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 operationTaskId = `work-order-operation-${operation.id}`;
tasks.push({
id: operationTaskId,
text: `${operation.station.code} - ${operation.station.name}`,
start: operation.plannedStart.toISOString(),
end: operation.plannedEnd.toISOString(),
progress: workOrderProgress(workOrder.quantity, workOrder.completedQuantity, workOrder.status),
type: "task",
parentId: workOrderTaskId,
status: workOrder.status,
ownerLabel: workOrder.workOrderNumber,
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
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}`,
source: previousOperationTaskId,
target: operationTaskId,
type: "e2e",
});
}
previousOperationTaskId = operationTaskId;
}
if (workOrder.dueDate === null) {
exceptions.push({
id: `work-order-unscheduled-${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(),
ownerLabel: "No project",
detailHref: `/manufacturing/work-orders/${workOrder.id}`,
});
}
}
}
const taskDates = tasks.flatMap((task) => [new Date(task.start), new Date(task.end)]);
const horizonStart = taskDates.length > 0 ? new Date(Math.min(...taskDates.map((date) => date.getTime()))) : startOfDay(now);
const horizonEnd = taskDates.length > 0 ? new Date(Math.max(...taskDates.map((date) => date.getTime()))) : addDays(startOfDay(now), 30);
return {
tasks,
links,
summary: {
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,
horizonStart: horizonStart.toISOString(),
horizonEnd: horizonEnd.toISOString(),
},
exceptions: exceptions
.sort((left, right) => {
if (!left.dueDate) {
return 1;
}
if (!right.dueDate) {
return -1;
}
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
})
.slice(0, 12),
};
}

View File

@@ -27,6 +27,15 @@ const bomLineSchema = z.object({
position: z.number().int().nonnegative(),
});
const operationSchema = z.object({
stationId: z.string().trim().min(1),
setupMinutes: z.number().int().nonnegative(),
runMinutesPerUnit: z.number().int().nonnegative(),
moveMinutes: z.number().int().nonnegative(),
position: z.number().int().nonnegative(),
notes: z.string(),
});
const inventoryItemSchema = z.object({
sku: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
@@ -40,6 +49,7 @@ const inventoryItemSchema = z.object({
defaultPrice: z.number().nonnegative().nullable(),
notes: z.string(),
bomLines: z.array(bomLineSchema),
operations: z.array(operationSchema),
});
const inventoryListQuerySchema = z.object({

View File

@@ -3,6 +3,7 @@ import type {
InventoryBomLineInput,
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOperationDto,
InventoryStockBalanceDto,
WarehouseDetailDto,
WarehouseInput,
@@ -34,6 +35,20 @@ type BomLineRecord = {
};
};
type OperationRecord = {
id: string;
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
notes: string;
position: number;
station: {
id: string;
code: string;
name: string;
};
};
type InventoryDetailRecord = {
id: string;
sku: string;
@@ -50,6 +65,7 @@ type InventoryDetailRecord = {
createdAt: Date;
updatedAt: Date;
bomLines: BomLineRecord[];
operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[];
};
@@ -106,6 +122,21 @@ function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
};
}
function mapOperation(record: OperationRecord): InventoryItemOperationDto {
return {
id: record.id,
stationId: record.station.id,
stationCode: record.station.code,
stationName: record.station.name,
setupMinutes: record.setupMinutes,
runMinutesPerUnit: record.runMinutesPerUnit,
moveMinutes: record.moveMinutes,
estimatedMinutesPerUnit: record.setupMinutes + record.runMinutesPerUnit + record.moveMinutes,
position: record.position,
notes: record.notes,
};
}
function mapWarehouseLocation(record: WarehouseLocationRecord): WarehouseLocationDto {
return {
id: record.id,
@@ -225,6 +256,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
operations: record.operations.slice().sort((a, b) => a.position - b.position).map(mapOperation),
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
stockBalances,
recentTransactions,
@@ -298,6 +330,19 @@ function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
.filter((line) => line.componentItemId.trim().length > 0);
}
function normalizeOperations(operations: InventoryItemInput["operations"]) {
return operations
.map((operation, index) => ({
stationId: operation.stationId,
setupMinutes: Number(operation.setupMinutes),
runMinutesPerUnit: Number(operation.runMinutesPerUnit),
moveMinutes: Number(operation.moveMinutes),
notes: operation.notes,
position: operation.position ?? (index + 1) * 10,
}))
.filter((operation) => operation.stationId.trim().length > 0);
}
function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
return locations
.map((location) => ({
@@ -346,6 +391,49 @@ async function validateBomLines(parentItemId: string | null, bomLines: Inventory
return { ok: true as const, bomLines: normalized };
}
async function validateOperations(type: InventoryItemType, operations: InventoryItemInput["operations"]) {
const normalized = normalizeOperations(operations);
if (type === "ASSEMBLY" || type === "MANUFACTURED") {
if (normalized.length === 0) {
return { ok: false as const, reason: "Assembly and manufactured items require at least one station operation." };
}
} else if (normalized.length > 0) {
return { ok: false as const, reason: "Only assembly and manufactured items may define station operations." };
}
if (normalized.some((operation) => operation.setupMinutes < 0 || operation.runMinutesPerUnit < 0 || operation.moveMinutes < 0)) {
return { ok: false as const, reason: "Operation times must be zero or greater." };
}
if (normalized.some((operation) => operation.setupMinutes + operation.runMinutesPerUnit + operation.moveMinutes <= 0)) {
return { ok: false as const, reason: "Each operation must have at least some planned time." };
}
const stationIds = [...new Set(normalized.map((operation) => operation.stationId))];
if (stationIds.length === 0) {
return { ok: true as const, operations: normalized };
}
const existingStations = await prisma.manufacturingStation.findMany({
where: {
id: {
in: stationIds,
},
isActive: true,
},
select: {
id: true,
},
});
if (existingStations.length !== stationIds.length) {
return { ok: false as const, reason: "One or more selected stations do not exist or are inactive." };
}
return { ok: true as const, operations: normalized };
}
export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters),
@@ -404,6 +492,18 @@ export async function getInventoryItemById(itemId: string) {
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
operations: {
include: {
station: {
select: {
id: true,
code: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
inventoryTransactions: {
include: {
warehouse: {
@@ -511,6 +611,10 @@ export async function createInventoryItem(payload: InventoryItemInput) {
if (!validatedBom.ok) {
return null;
}
const validatedOperations = await validateOperations(payload.type, payload.operations);
if (!validatedOperations.ok) {
return null;
}
const item = await prisma.inventoryItem.create({
data: {
@@ -530,6 +634,11 @@ export async function createInventoryItem(payload: InventoryItemInput) {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
@@ -552,6 +661,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedBom.ok) {
return null;
}
const validatedOperations = await validateOperations(payload.type, payload.operations);
if (!validatedOperations.ok) {
return null;
}
const item = await prisma.inventoryItem.update({
where: { id: itemId },
@@ -571,6 +684,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
deleteMany: {},
create: validatedBom.bomLines,
},
operations: {
deleteMany: {},
create: validatedOperations.operations,
},
},
select: {
id: true,

View File

@@ -6,17 +6,27 @@ import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createManufacturingStation,
createWorkOrder,
getWorkOrderById,
issueWorkOrderMaterial,
listManufacturingItemOptions,
listManufacturingProjectOptions,
listManufacturingStations,
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderStatus,
} from "./service.js";
const stationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
description: z.string(),
queueDays: z.number().int().min(0).max(365),
isActive: z.boolean(),
});
const workOrderSchema = z.object({
itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(),
@@ -66,6 +76,19 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations());
});
manufacturingRouter.post("/stations", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const parsed = stationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Manufacturing station payload is invalid.");
}
return ok(response, await createManufacturingStation(parsed.data), 201);
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const parsed = workOrderFiltersSchema.safeParse(request.query);
if (!parsed.success) {

View File

@@ -1,9 +1,12 @@
import type {
ManufacturingStationDto,
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationDto,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -13,6 +16,17 @@ import { prisma } from "../../lib/prisma.js";
const workOrderModel = (prisma as any).workOrder;
type StationRecord = {
id: string;
code: string;
name: string;
description: string;
queueDays: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
type WorkOrderRecord = {
id: string;
workOrderNumber: string;
@@ -29,6 +43,19 @@ type WorkOrderRecord = {
name: string;
type: string;
unitOfMeasure: string;
operations: Array<{
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
position: number;
notes: string;
station: {
id: string;
code: string;
name: string;
queueDays: number;
};
}>;
bomLines: Array<{
quantity: number;
unitOfMeasure: string;
@@ -57,6 +84,22 @@ type WorkOrderRecord = {
code: string;
name: string;
};
operations: Array<{
id: string;
sequence: number;
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
plannedMinutes: number;
plannedStart: Date;
plannedEnd: Date;
notes: string;
station: {
id: string;
code: string;
name: string;
};
}>;
materialIssues: Array<{
id: string;
quantity: number;
@@ -94,10 +137,36 @@ type WorkOrderRecord = {
}>;
};
function mapStation(record: StationRecord): ManufacturingStationDto {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
queueDays: record.queueDays,
isActive: record.isActive,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function buildInclude() {
return {
item: {
include: {
operations: {
include: {
station: {
select: {
id: true,
code: true,
name: true,
queueDays: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
bomLines: {
include: {
componentItem: {
@@ -135,6 +204,18 @@ function buildInclude() {
name: true,
},
},
operations: {
include: {
station: {
select: {
id: true,
code: true,
name: true,
},
},
},
orderBy: [{ sequence: "asc" }],
},
materialIssues: {
include: {
componentItem: {
@@ -205,6 +286,8 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
operationCount: record.operations.length,
totalPlannedMinutes: record.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
updatedAt: record.updatedAt.toISOString(),
};
}
@@ -224,6 +307,20 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
itemUnitOfMeasure: record.item.unitOfMeasure,
projectCustomerName: record.project?.customer.name ?? null,
dueQuantity: record.quantity - record.completedQuantity,
operations: record.operations.map((operation): WorkOrderOperationDto => ({
id: operation.id,
stationId: operation.station.id,
stationCode: operation.station.code,
stationName: operation.station.name,
sequence: operation.sequence,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
plannedMinutes: operation.plannedMinutes,
plannedStart: operation.plannedStart.toISOString(),
plannedEnd: operation.plannedEnd.toISOString(),
notes: operation.notes,
})),
materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
@@ -265,6 +362,107 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
};
}
function addMinutes(value: Date, minutes: number) {
return new Date(value.getTime() + minutes * 60 * 1000);
}
function buildWorkOrderOperationPlan(
itemOperations: WorkOrderRecord["item"]["operations"],
quantity: number,
dueDate: Date | null,
fallbackStart: Date
) {
if (itemOperations.length === 0) {
return [];
}
const operationDurations = itemOperations.map((operation) => {
const plannedMinutes = Math.max(operation.setupMinutes + operation.runMinutesPerUnit * quantity + operation.moveMinutes + operation.station.queueDays * 8 * 60, 1);
return {
stationId: operation.station.id,
sequence: operation.position,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
plannedMinutes,
notes: operation.notes,
};
});
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);
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;
return planned;
});
}
async function regenerateWorkOrderOperations(workOrderId: string) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return;
}
const plan = buildWorkOrderOperationPlan(
(workOrder as WorkOrderRecord).item.operations,
workOrder.quantity,
workOrder.dueDate,
workOrder.createdAt
);
await prisma.workOrderOperation.deleteMany({
where: {
workOrderId,
},
});
if (plan.length === 0) {
return;
}
await prisma.workOrderOperation.createMany({
data: plan.map((operation) => ({
workOrderId,
stationId: operation.stationId,
sequence: operation.sequence,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
plannedMinutes: operation.plannedMinutes,
plannedStart: operation.plannedStart,
plannedEnd: operation.plannedEnd,
notes: operation.notes,
})),
});
}
async function nextWorkOrderNumber() {
const next = (await workOrderModel.count()) + 1;
return `WO-${String(next).padStart(5, "0")}`;
@@ -295,6 +493,11 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
id: true,
type: true,
status: true,
_count: {
select: {
operations: true,
},
},
},
});
@@ -310,6 +513,10 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
return { ok: false as const, reason: "Work orders can only be created for assembly or manufactured items." };
}
if (item._count.operations === 0) {
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
}
if (payload.projectId) {
const project = await prisma.project.findUnique({
where: { id: payload.projectId },
@@ -350,11 +557,51 @@ export async function listManufacturingItemOptions(): Promise<ManufacturingItemO
name: true,
type: true,
unitOfMeasure: true,
operations: {
select: {
setupMinutes: true,
runMinutesPerUnit: true,
moveMinutes: true,
},
},
},
orderBy: [{ sku: "asc" }],
});
return items;
return items.map((item) => ({
id: item.id,
sku: item.sku,
name: item.name,
type: item.type,
unitOfMeasure: item.unitOfMeasure,
operationCount: item.operations.length,
totalEstimatedMinutesPerUnit: item.operations.reduce(
(sum, operation) => sum + operation.setupMinutes + operation.runMinutesPerUnit + operation.moveMinutes,
0
),
}));
}
export async function listManufacturingStations(): Promise<ManufacturingStationDto[]> {
const stations = await prisma.manufacturingStation.findMany({
orderBy: [{ code: "asc" }],
});
return stations.map(mapStation);
}
export async function createManufacturingStation(payload: ManufacturingStationInput) {
const station = await prisma.manufacturingStation.create({
data: {
code: payload.code.trim(),
name: payload.name.trim(),
description: payload.description,
queueDays: payload.queueDays,
isActive: payload.isActive,
},
});
return mapStation(station);
}
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
@@ -448,6 +695,8 @@ export async function createWorkOrder(payload: WorkOrderInput) {
},
});
await regenerateWorkOrderOperations(created.id);
const workOrder = await getWorkOrderById(created.id);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
@@ -488,6 +737,8 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
},
});
await regenerateWorkOrderOperations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}

View File

@@ -5,6 +5,10 @@ export interface GanttTaskDto {
end: string;
progress: number;
type: "task" | "project" | "milestone";
parentId?: string | null;
status?: string;
ownerLabel?: string | null;
detailHref?: string | null;
}
export interface GanttLinkDto {
@@ -14,3 +18,30 @@ export interface GanttLinkDto {
type: string;
}
export interface PlanningSummaryDto {
activeProjects: number;
atRiskProjects: number;
overdueProjects: number;
activeWorkOrders: number;
overdueWorkOrders: number;
unscheduledWorkOrders: number;
horizonStart: string;
horizonEnd: string;
}
export interface PlanningExceptionDto {
id: string;
kind: "PROJECT" | "WORK_ORDER";
title: string;
status: string;
dueDate: string | null;
ownerLabel: string | null;
detailHref: string;
}
export interface PlanningTimelineDto {
tasks: GanttTaskDto[];
links: GanttLinkDto[];
summary: PlanningSummaryDto;
exceptions: PlanningExceptionDto[];
}

View File

@@ -27,6 +27,28 @@ export interface InventoryBomLineInput {
position: number;
}
export interface InventoryItemOperationDto {
id: string;
stationId: string;
stationCode: string;
stationName: string;
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
estimatedMinutesPerUnit: number;
position: number;
notes: string;
}
export interface InventoryItemOperationInput {
stationId: string;
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
position: number;
notes: string;
}
export interface InventoryItemOptionDto {
id: string;
sku: string;
@@ -134,6 +156,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
notes: string;
createdAt: string;
bomLines: InventoryBomLineDto[];
operations: InventoryItemOperationDto[];
onHandQuantity: number;
stockBalances: InventoryStockBalanceDto[];
recentTransactions: InventoryTransactionDto[];
@@ -152,4 +175,5 @@ export interface InventoryItemInput {
defaultPrice: number | null;
notes: string;
bomLines: InventoryBomLineInput[];
operations: InventoryItemOperationInput[];
}

View File

@@ -2,6 +2,25 @@ export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD",
export type WorkOrderStatus = (typeof workOrderStatuses)[number];
export interface ManufacturingStationDto {
id: string;
code: string;
name: string;
description: string;
queueDays: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface ManufacturingStationInput {
code: string;
name: string;
description: string;
queueDays: number;
isActive: boolean;
}
export interface ManufacturingProjectOptionDto {
id: string;
projectNumber: string;
@@ -16,6 +35,8 @@ export interface ManufacturingItemOptionDto {
name: string;
type: string;
unitOfMeasure: string;
operationCount: number;
totalEstimatedMinutesPerUnit: number;
}
export interface WorkOrderSummaryDto {
@@ -37,9 +58,26 @@ export interface WorkOrderSummaryDto {
locationId: string;
locationCode: string;
locationName: string;
operationCount: number;
totalPlannedMinutes: number;
updatedAt: string;
}
export interface WorkOrderOperationDto {
id: string;
stationId: string;
stationCode: string;
stationName: string;
sequence: number;
setupMinutes: number;
runMinutesPerUnit: number;
moveMinutes: number;
plannedMinutes: number;
plannedStart: string;
plannedEnd: string;
notes: string;
}
export interface WorkOrderMaterialRequirementDto {
componentItemId: string;
componentSku: string;
@@ -83,6 +121,7 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
itemUnitOfMeasure: string;
projectCustomerName: string | null;
dueQuantity: number;
operations: WorkOrderOperationDto[];
materialRequirements: WorkOrderMaterialRequirementDto[];
materialIssues: WorkOrderMaterialIssueDto[];
completions: WorkOrderCompletionDto[];