manufacturing and gantt
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
35
README.md
35
README.md
@@ -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.
|
||||
|
||||
|
||||
16
ROADMAP.md
16
ROADMAP.md
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,49 +1,167 @@
|
||||
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">
|
||||
<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-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>
|
||||
<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 mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4 ${
|
||||
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) => ({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
460
server/src/modules/gantt/service.ts
Normal file
460
server/src/modules/gantt/service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user