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
|
- purchase-order supporting documents and vendor-side purchasing visibility
|
||||||
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
|
- 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
|
- 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
|
- Puppeteer PDF foundation
|
||||||
- single-container Docker deployment
|
- single-container Docker deployment
|
||||||
|
|
||||||
@@ -117,10 +118,9 @@ If implementation changes invalidate those docs, update them in the same change
|
|||||||
|
|
||||||
Near-term priorities are:
|
Near-term priorities are:
|
||||||
|
|
||||||
1. Planning and gantt scheduling with live project/manufacturing data
|
1. Inventory transfers, reservations, and deeper stock controls
|
||||||
2. Inventory transfers, reservations, and deeper stock controls
|
2. Broader audit-trail coverage and operational diagnostics
|
||||||
3. Broader audit-trail coverage and operational diagnostics
|
3. Code-splitting and bundle-size reduction
|
||||||
4. Code-splitting and bundle-size reduction
|
|
||||||
|
|
||||||
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.
|
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
|
### 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
|
- 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
|
- 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
|
- 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 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 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
|
- 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 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
|
- 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
|
- 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
|
- 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
|
## 2026-03-15
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ This repository implements the platform foundation milestone:
|
|||||||
- purchase-order supporting documents and vendor-side purchasing visibility
|
- 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
|
- 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
|
- 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
|
- Dockerized single-container deployment
|
||||||
- Puppeteer PDF pipeline foundation
|
- Puppeteer PDF pipeline foundation
|
||||||
|
|
||||||
@@ -60,7 +61,6 @@ This repository implements the platform foundation milestone:
|
|||||||
|
|
||||||
## Next roadmap candidates
|
## Next roadmap candidates
|
||||||
|
|
||||||
- planning and gantt scheduling with live project/manufacturing data
|
|
||||||
- inventory transfers, reservations, and deeper stock controls
|
- inventory transfers, reservations, and deeper stock controls
|
||||||
- broader audit and operations maturity
|
- broader audit and operations maturity
|
||||||
- code-splitting and bundle-size reduction
|
- 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
|
- 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
|
- 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
|
- 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
|
- file storage and PDF rendering
|
||||||
|
|
||||||
## Product Map
|
## Product Map
|
||||||
@@ -37,18 +38,14 @@ Current completed foundation areas:
|
|||||||
- shipping foundation
|
- shipping foundation
|
||||||
- projects foundation
|
- projects foundation
|
||||||
- manufacturing foundation
|
- manufacturing foundation
|
||||||
|
- planning foundation
|
||||||
- branding, attachments, auth/RBAC, and PDF infrastructure
|
- branding, attachments, auth/RBAC, and PDF infrastructure
|
||||||
|
|
||||||
Planned cross-module execution areas:
|
|
||||||
|
|
||||||
- planning and gantt scheduling
|
|
||||||
|
|
||||||
Near-term priorities:
|
Near-term priorities:
|
||||||
|
|
||||||
1. Planning and gantt scheduling with live project/manufacturing data
|
1. Inventory transfers, reservations, and deeper stock controls
|
||||||
2. Inventory transfers, reservations, and deeper stock controls
|
2. Broader audit-trail coverage and operational diagnostics
|
||||||
3. Broader audit-trail coverage and operational diagnostics
|
3. Code-splitting and bundle-size reduction
|
||||||
4. Code-splitting and bundle-size reduction
|
|
||||||
|
|
||||||
Revisit / deferred items:
|
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
|
- 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
|
- 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
|
- 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
|
- future project widgets should deepen milestones, shortages, and shipment readiness
|
||||||
|
|
||||||
Navigation direction:
|
Navigation direction:
|
||||||
@@ -94,7 +92,7 @@ Next expansion areas:
|
|||||||
|
|
||||||
## Manufacturing Direction
|
## 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:
|
Current interactions:
|
||||||
|
|
||||||
@@ -108,6 +106,22 @@ Next expansion areas:
|
|||||||
- Shipping: completed manufacturing should feed shipment readiness
|
- Shipping: completed manufacturing should feed shipment readiness
|
||||||
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
|
- Planning: manufacturing orders, routings, and work centers 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
|
## Workspace
|
||||||
|
|
||||||
- `client`: React, Vite, Tailwind frontend
|
- `client`: React, Vite, Tailwind frontend
|
||||||
@@ -318,6 +332,7 @@ As of March 14, 2026, the latest committed domain migrations include:
|
|||||||
- shipping foundation
|
- shipping foundation
|
||||||
- projects foundation
|
- projects foundation
|
||||||
- manufacturing 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.
|
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
|
- 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
|
- 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 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 invoice/supporting-document attachments directly on purchase orders
|
||||||
- Vendor-detail purchasing visibility with recent purchase-order activity
|
- Vendor-detail purchasing visibility with recent purchase-order activity
|
||||||
- SKU-searchable BOM component selection for inventory-scale datasets
|
- SKU-searchable BOM component selection for inventory-scale datasets
|
||||||
- Theme persistence fixes and denser responsive workspace layouts
|
- Theme persistence fixes and denser responsive workspace layouts
|
||||||
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
|
- 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
|
- Multi-stage Docker packaging and migration-aware entrypoint
|
||||||
- Docker image validated locally with successful app startup and login flow
|
- Docker image validated locally with successful app startup and login flow
|
||||||
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
|
||||||
@@ -222,6 +223,12 @@ QOL subfeatures:
|
|||||||
|
|
||||||
### Phase 7: Planning and scheduling
|
### 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
|
- Live project-backed SVAR gantt timelines
|
||||||
- Task dependencies, milestones, and progress updates
|
- Task dependencies, milestones, and progress updates
|
||||||
- Manufacturing calendar views and bottleneck visibility
|
- Manufacturing calendar views and bottleneck visibility
|
||||||
@@ -275,7 +282,6 @@ QOL subfeatures:
|
|||||||
|
|
||||||
## Near-term priority order
|
## Near-term priority order
|
||||||
|
|
||||||
1. Planning and scheduling with live project/manufacturing data
|
1. Inventory transfers, reservations, and deeper stock controls
|
||||||
2. Inventory transfers, reservations, and deeper stock controls
|
2. Broader audit-trail coverage and operational diagnostics
|
||||||
3. Broader audit-trail coverage and operational diagnostics
|
3. Code-splitting and bundle-size reduction
|
||||||
4. Code-splitting and bundle-size reduction
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type {
|
|||||||
CompanyProfileDto,
|
CompanyProfileDto,
|
||||||
CompanyProfileInput,
|
CompanyProfileInput,
|
||||||
FileAttachmentDto,
|
FileAttachmentDto,
|
||||||
GanttLinkDto,
|
PlanningTimelineDto,
|
||||||
GanttTaskDto,
|
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
@@ -34,6 +33,8 @@ import type {
|
|||||||
WarehouseSummaryDto,
|
WarehouseSummaryDto,
|
||||||
} from "@mrp/shared/dist/inventory/types.js";
|
} from "@mrp/shared/dist/inventory/types.js";
|
||||||
import type {
|
import type {
|
||||||
|
ManufacturingStationDto,
|
||||||
|
ManufacturingStationInput,
|
||||||
ManufacturingItemOptionDto,
|
ManufacturingItemOptionDto,
|
||||||
ManufacturingProjectOptionDto,
|
ManufacturingProjectOptionDto,
|
||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
@@ -461,6 +462,12 @@ export const api = {
|
|||||||
getManufacturingProjectOptions(token: string) {
|
getManufacturingProjectOptions(token: string) {
|
||||||
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
|
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 }) {
|
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
|
||||||
return request<WorkOrderSummaryDto[]>(
|
return request<WorkOrderSummaryDto[]>(
|
||||||
`/api/v1/manufacturing/work-orders${buildQueryString({
|
`/api/v1/manufacturing/work-orders${buildQueryString({
|
||||||
@@ -503,8 +510,8 @@ export const api = {
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getGanttDemo(token: string) {
|
getPlanningTimeline(token: string) {
|
||||||
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
|
return request<PlanningTimelineDto>("/api/v1/gantt/timeline", undefined, token);
|
||||||
},
|
},
|
||||||
getSalesCustomers(token: string) {
|
getSalesCustomers(token: string) {
|
||||||
return request<SalesCustomerOptionDto[]>("/api/v1/sales/customers/options", undefined, token);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
|
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
|
||||||
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
|
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
|
||||||
|
const canReadPlanning = hasPermission(user?.permissions, permissions.ganttRead);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !user) {
|
if (!token || !user) {
|
||||||
@@ -150,6 +151,7 @@ export function DashboardPage() {
|
|||||||
snapshot?.quotes !== null || snapshot?.orders !== null,
|
snapshot?.quotes !== null || snapshot?.orders !== null,
|
||||||
snapshot?.shipments !== null,
|
snapshot?.shipments !== null,
|
||||||
snapshot?.projects !== null,
|
snapshot?.projects !== null,
|
||||||
|
canReadPlanning,
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
const customerCount = customers.length;
|
const customerCount = customers.length;
|
||||||
@@ -398,12 +400,24 @@ export function DashboardPage() {
|
|||||||
...(canWriteProjects ? [{ label: "New project", to: "/projects/new" }] : []),
|
...(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 = [
|
const futureModules = [
|
||||||
"Stock transfers, allocations, and cycle counts",
|
"Stock transfers, allocations, and cycle counts",
|
||||||
"Planning timeline, milestones, and dependency views",
|
"Revision comparison and document restore tooling",
|
||||||
"Sales approvals, revisions, and change history",
|
|
||||||
"Audit trails, diagnostics, and system health checks",
|
"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">
|
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
|
||||||
Open manufacturing
|
Open manufacturing
|
||||||
</Link>
|
</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>
|
</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}
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -1,48 +1,166 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Gantt } from "@svar-ui/react-gantt";
|
import { Gantt } from "@svar-ui/react-gantt";
|
||||||
import "@svar-ui/react-gantt/style.css";
|
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 { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api } from "../../lib/api";
|
import { ApiError, api } from "../../lib/api";
|
||||||
import { useTheme } from "../../theme/ThemeProvider";
|
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() {
|
export function GanttPage() {
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
const [tasks, setTasks] = useState<GanttTaskDto[]>([]);
|
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
|
||||||
const [links, setLinks] = useState<GanttLinkDto[]>([]);
|
const [status, setStatus] = useState("Loading live planning timeline...");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.getGanttDemo(token).then((data) => {
|
api
|
||||||
setTasks(data.tasks);
|
.getPlanningTimeline(token)
|
||||||
setLinks(data.links);
|
.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]);
|
}, [token]);
|
||||||
|
|
||||||
|
const tasks = timeline?.tasks ?? [];
|
||||||
|
const links = timeline?.links ?? [];
|
||||||
|
const summary = timeline?.summary;
|
||||||
|
const exceptions = timeline?.exceptions ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-8 shadow-panel">
|
<section className="space-y-4">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
<h3 className="mt-3 text-2xl font-bold text-text">SVAR Gantt Preview</h3>
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<p className="mt-2 text-sm text-muted">Theme-aware integration wrapper prepared for future manufacturing schedules and task dependencies.</p>
|
<div>
|
||||||
<div
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning</p>
|
||||||
className={`gantt-theme mt-6 overflow-hidden rounded-2xl border border-line/70 bg-page/70 p-4 ${
|
<h3 className="mt-2 text-2xl font-bold text-text">Live Project + Manufacturing Gantt</h3>
|
||||||
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
<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>
|
||||||
<Gantt
|
</div>
|
||||||
tasks={tasks.map((task) => ({
|
<div className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
||||||
...task,
|
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Timeline Status</div>
|
||||||
start: new Date(task.start),
|
<div className="mt-2 font-semibold text-text">{status}</div>
|
||||||
end: new Date(task.end),
|
</div>
|
||||||
}))}
|
</div>
|
||||||
links={links}
|
</div>
|
||||||
/>
|
<section className="grid gap-3 xl:grid-cols-6">
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Projects</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">At Risk</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.atRiskProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Projects</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueProjects ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.activeWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Overdue Work</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.overdueWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
|
||||||
|
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||||
|
<div
|
||||||
|
className={`gantt-theme overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5 ${
|
||||||
|
mode === "dark" ? "wx-willow-dark-theme" : "wx-willow-theme"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Schedule Window</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">
|
||||||
|
{summary ? `${formatDate(summary.horizonStart)} through ${formatDate(summary.horizonEnd)}` : "Waiting for live schedule data."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-line/70 bg-page/60 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||||
|
{tasks.length} schedule rows
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Gantt
|
||||||
|
tasks={tasks.map((task: GanttTaskDto) => ({
|
||||||
|
...task,
|
||||||
|
start: new Date(task.start),
|
||||||
|
end: new Date(task.end),
|
||||||
|
parent: task.parentId ?? undefined,
|
||||||
|
}))}
|
||||||
|
links={links}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="space-y-3">
|
||||||
|
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planning Exceptions</p>
|
||||||
|
<p className="mt-2 text-sm text-muted">Priority schedule issues from live project due dates and manufacturing commitments.</p>
|
||||||
|
{exceptions.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
|
||||||
|
No planning exceptions are active.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{exceptions.map((exception: PlanningExceptionDto) => (
|
||||||
|
<Link key={exception.id} to={exception.detailHref} className="block rounded-3xl border border-line/70 bg-page/60 p-3 transition hover:bg-page/80">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">{exception.kind === "PROJECT" ? "Project" : "Work Order"}</div>
|
||||||
|
<div className="mt-1 font-semibold text-text">{exception.title}</div>
|
||||||
|
<div className="mt-2 text-xs text-muted">{exception.ownerLabel ?? "No owner context"}</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-line/70 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||||
|
{exception.status.replaceAll("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-muted">Due: {formatDate(exception.dueDate)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open projects
|
||||||
|
</Link>
|
||||||
|
<Link to="/manufacturing/work-orders" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Open work orders
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function InventoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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>
|
<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>
|
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</div>
|
||||||
</article>
|
</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>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
|
<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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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)]">
|
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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 { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../../auth/AuthProvider";
|
import { useAuth } from "../../auth/AuthProvider";
|
||||||
import { api, ApiError } from "../../lib/api";
|
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 {
|
interface InventoryFormPageProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
@@ -16,6 +17,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
const { itemId } = useParams();
|
const { itemId } = useParams();
|
||||||
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
|
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
|
||||||
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
|
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
|
||||||
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
||||||
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
|
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
|
||||||
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
|
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
|
||||||
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
|
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,
|
notes: line.notes,
|
||||||
position: line.position,
|
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));
|
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
|
||||||
setStatus("Inventory item loaded.");
|
setStatus("Inventory item loaded.");
|
||||||
@@ -90,6 +100,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
});
|
});
|
||||||
}, [itemId, mode, token]);
|
}, [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]) {
|
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
@@ -123,6 +141,33 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
setComponentSearchTerms((current) => [...current, ""]);
|
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) {
|
function removeBomLine(index: number) {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -289,6 +334,74 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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">
|
<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 className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
inventoryUnitsOfMeasure,
|
inventoryUnitsOfMeasure,
|
||||||
type InventoryBomLineInput,
|
type InventoryBomLineInput,
|
||||||
type InventoryItemInput,
|
type InventoryItemInput,
|
||||||
|
type InventoryItemOperationInput,
|
||||||
type WarehouseInput,
|
type WarehouseInput,
|
||||||
type WarehouseLocationInput,
|
type WarehouseLocationInput,
|
||||||
type InventoryItemStatus,
|
type InventoryItemStatus,
|
||||||
@@ -22,6 +23,15 @@ export const emptyInventoryBomLineInput: InventoryBomLineInput = {
|
|||||||
position: 10,
|
position: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||||
|
stationId: "",
|
||||||
|
setupMinutes: 0,
|
||||||
|
runMinutesPerUnit: 0,
|
||||||
|
moveMinutes: 0,
|
||||||
|
position: 10,
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
|
||||||
export const emptyInventoryItemInput: InventoryItemInput = {
|
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||||
sku: "",
|
sku: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -35,6 +45,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
|
|||||||
defaultPrice: null,
|
defaultPrice: null,
|
||||||
notes: "",
|
notes: "",
|
||||||
bomLines: [],
|
bomLines: [],
|
||||||
|
operations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
|
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";
|
import { WorkOrderListPage } from "./WorkOrderListPage";
|
||||||
|
|
||||||
|
const emptyStationInput: ManufacturingStationInput = {
|
||||||
|
code: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
queueDays: 0,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
export function ManufacturingPage() {
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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">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">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">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">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>
|
<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>
|
</section>
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
<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>
|
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</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 ? (
|
{canManage ? (
|
||||||
<section className="grid gap-3 xl:grid-cols-2">
|
<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">
|
<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);
|
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">
|
}} 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="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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function WorkOrderListPage() {
|
|||||||
<th className="px-3 py-3">Status</th>
|
<th className="px-3 py-3">Status</th>
|
||||||
<th className="px-3 py-3">Qty</th>
|
<th className="px-3 py-3">Qty</th>
|
||||||
<th className="px-3 py-3">Location</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>
|
<th className="px-3 py-3">Due</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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"><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.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.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>
|
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
|
||||||
</tr>
|
</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[]
|
purchaseOrderLines PurchaseOrderLine[]
|
||||||
workOrders WorkOrder[]
|
workOrders WorkOrder[]
|
||||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||||
|
operations InventoryItemOperation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Warehouse {
|
model Warehouse {
|
||||||
@@ -476,6 +477,7 @@ model WorkOrder {
|
|||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
|
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
|
||||||
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||||
|
operations WorkOrderOperation[]
|
||||||
materialIssues WorkOrderMaterialIssue[]
|
materialIssues WorkOrderMaterialIssue[]
|
||||||
completions WorkOrderCompletion[]
|
completions WorkOrderCompletion[]
|
||||||
|
|
||||||
@@ -485,6 +487,58 @@ model WorkOrder {
|
|||||||
@@index([warehouseId, createdAt])
|
@@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 {
|
model WorkOrderMaterialIssue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
workOrderId String
|
workOrderId String
|
||||||
|
|||||||
@@ -3,21 +3,10 @@ import { Router } from "express";
|
|||||||
|
|
||||||
import { ok } from "../../lib/http.js";
|
import { ok } from "../../lib/http.js";
|
||||||
import { requirePermissions } from "../../lib/rbac.js";
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
|
import { getPlanningTimeline } from "./service.js";
|
||||||
|
|
||||||
export const ganttRouter = Router();
|
export const ganttRouter = Router();
|
||||||
|
|
||||||
ganttRouter.get("/demo", requirePermissions([permissions.ganttRead]), (_request, response) => {
|
ganttRouter.get("/timeline", requirePermissions([permissions.ganttRead]), async (_request, response) => {
|
||||||
return ok(response, {
|
return ok(response, await getPlanningTimeline());
|
||||||
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" }
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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(),
|
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({
|
const inventoryItemSchema = z.object({
|
||||||
sku: z.string().trim().min(1).max(64),
|
sku: z.string().trim().min(1).max(64),
|
||||||
name: z.string().trim().min(1).max(160),
|
name: z.string().trim().min(1).max(160),
|
||||||
@@ -40,6 +49,7 @@ const inventoryItemSchema = z.object({
|
|||||||
defaultPrice: z.number().nonnegative().nullable(),
|
defaultPrice: z.number().nonnegative().nullable(),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
bomLines: z.array(bomLineSchema),
|
bomLines: z.array(bomLineSchema),
|
||||||
|
operations: z.array(operationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const inventoryListQuerySchema = z.object({
|
const inventoryListQuerySchema = z.object({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
InventoryBomLineInput,
|
InventoryBomLineInput,
|
||||||
InventoryItemDetailDto,
|
InventoryItemDetailDto,
|
||||||
InventoryItemInput,
|
InventoryItemInput,
|
||||||
|
InventoryItemOperationDto,
|
||||||
InventoryStockBalanceDto,
|
InventoryStockBalanceDto,
|
||||||
WarehouseDetailDto,
|
WarehouseDetailDto,
|
||||||
WarehouseInput,
|
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 = {
|
type InventoryDetailRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
@@ -50,6 +65,7 @@ type InventoryDetailRecord = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
bomLines: BomLineRecord[];
|
bomLines: BomLineRecord[];
|
||||||
|
operations: OperationRecord[];
|
||||||
inventoryTransactions: InventoryTransactionRecord[];
|
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 {
|
function mapWarehouseLocation(record: WarehouseLocationRecord): WarehouseLocationDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -225,6 +256,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
|
|||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
|
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),
|
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
|
||||||
stockBalances,
|
stockBalances,
|
||||||
recentTransactions,
|
recentTransactions,
|
||||||
@@ -298,6 +330,19 @@ function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
|
|||||||
.filter((line) => line.componentItemId.trim().length > 0);
|
.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[]) {
|
function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
|
||||||
return locations
|
return locations
|
||||||
.map((location) => ({
|
.map((location) => ({
|
||||||
@@ -346,6 +391,49 @@ async function validateBomLines(parentItemId: string | null, bomLines: Inventory
|
|||||||
return { ok: true as const, bomLines: normalized };
|
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 = {}) {
|
export async function listInventoryItems(filters: InventoryListFilters = {}) {
|
||||||
const items = await prisma.inventoryItem.findMany({
|
const items = await prisma.inventoryItem.findMany({
|
||||||
where: buildWhereClause(filters),
|
where: buildWhereClause(filters),
|
||||||
@@ -404,6 +492,18 @@ export async function getInventoryItemById(itemId: string) {
|
|||||||
},
|
},
|
||||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
},
|
},
|
||||||
|
operations: {
|
||||||
|
include: {
|
||||||
|
station: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
inventoryTransactions: {
|
inventoryTransactions: {
|
||||||
include: {
|
include: {
|
||||||
warehouse: {
|
warehouse: {
|
||||||
@@ -511,6 +611,10 @@ export async function createInventoryItem(payload: InventoryItemInput) {
|
|||||||
if (!validatedBom.ok) {
|
if (!validatedBom.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const validatedOperations = await validateOperations(payload.type, payload.operations);
|
||||||
|
if (!validatedOperations.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await prisma.inventoryItem.create({
|
const item = await prisma.inventoryItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -530,6 +634,11 @@ export async function createInventoryItem(payload: InventoryItemInput) {
|
|||||||
create: validatedBom.bomLines,
|
create: validatedBom.bomLines,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
operations: validatedOperations.operations.length
|
||||||
|
? {
|
||||||
|
create: validatedOperations.operations,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -552,6 +661,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
|||||||
if (!validatedBom.ok) {
|
if (!validatedBom.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const validatedOperations = await validateOperations(payload.type, payload.operations);
|
||||||
|
if (!validatedOperations.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await prisma.inventoryItem.update({
|
const item = await prisma.inventoryItem.update({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
@@ -571,6 +684,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
|||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: validatedBom.bomLines,
|
create: validatedBom.bomLines,
|
||||||
},
|
},
|
||||||
|
operations: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: validatedOperations.operations,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -6,17 +6,27 @@ import { z } from "zod";
|
|||||||
import { fail, ok } from "../../lib/http.js";
|
import { fail, ok } from "../../lib/http.js";
|
||||||
import { requirePermissions } from "../../lib/rbac.js";
|
import { requirePermissions } from "../../lib/rbac.js";
|
||||||
import {
|
import {
|
||||||
|
createManufacturingStation,
|
||||||
createWorkOrder,
|
createWorkOrder,
|
||||||
getWorkOrderById,
|
getWorkOrderById,
|
||||||
issueWorkOrderMaterial,
|
issueWorkOrderMaterial,
|
||||||
listManufacturingItemOptions,
|
listManufacturingItemOptions,
|
||||||
listManufacturingProjectOptions,
|
listManufacturingProjectOptions,
|
||||||
|
listManufacturingStations,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
recordWorkOrderCompletion,
|
recordWorkOrderCompletion,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
updateWorkOrderStatus,
|
updateWorkOrderStatus,
|
||||||
} from "./service.js";
|
} 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({
|
const workOrderSchema = z.object({
|
||||||
itemId: z.string().trim().min(1),
|
itemId: z.string().trim().min(1),
|
||||||
projectId: z.string().trim().min(1).nullable(),
|
projectId: z.string().trim().min(1).nullable(),
|
||||||
@@ -66,6 +76,19 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
|
|||||||
return ok(response, await listManufacturingProjectOptions());
|
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) => {
|
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
|
||||||
const parsed = workOrderFiltersSchema.safeParse(request.query);
|
const parsed = workOrderFiltersSchema.safeParse(request.query);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ManufacturingStationDto,
|
||||||
|
ManufacturingStationInput,
|
||||||
ManufacturingItemOptionDto,
|
ManufacturingItemOptionDto,
|
||||||
ManufacturingProjectOptionDto,
|
ManufacturingProjectOptionDto,
|
||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationDto,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
@@ -13,6 +16,17 @@ import { prisma } from "../../lib/prisma.js";
|
|||||||
|
|
||||||
const workOrderModel = (prisma as any).workOrder;
|
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 = {
|
type WorkOrderRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
workOrderNumber: string;
|
workOrderNumber: string;
|
||||||
@@ -29,6 +43,19 @@ type WorkOrderRecord = {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
unitOfMeasure: 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<{
|
bomLines: Array<{
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitOfMeasure: string;
|
unitOfMeasure: string;
|
||||||
@@ -57,6 +84,22 @@ type WorkOrderRecord = {
|
|||||||
code: string;
|
code: string;
|
||||||
name: 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<{
|
materialIssues: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
quantity: number;
|
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() {
|
function buildInclude() {
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
include: {
|
include: {
|
||||||
|
operations: {
|
||||||
|
include: {
|
||||||
|
station: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
queueDays: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
bomLines: {
|
bomLines: {
|
||||||
include: {
|
include: {
|
||||||
componentItem: {
|
componentItem: {
|
||||||
@@ -135,6 +204,18 @@ function buildInclude() {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
operations: {
|
||||||
|
include: {
|
||||||
|
station: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sequence: "asc" }],
|
||||||
|
},
|
||||||
materialIssues: {
|
materialIssues: {
|
||||||
include: {
|
include: {
|
||||||
componentItem: {
|
componentItem: {
|
||||||
@@ -205,6 +286,8 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
|
|||||||
locationId: record.location.id,
|
locationId: record.location.id,
|
||||||
locationCode: record.location.code,
|
locationCode: record.location.code,
|
||||||
locationName: record.location.name,
|
locationName: record.location.name,
|
||||||
|
operationCount: record.operations.length,
|
||||||
|
totalPlannedMinutes: record.operations.reduce((sum, operation) => sum + operation.plannedMinutes, 0),
|
||||||
updatedAt: record.updatedAt.toISOString(),
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,6 +307,20 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
|
|||||||
itemUnitOfMeasure: record.item.unitOfMeasure,
|
itemUnitOfMeasure: record.item.unitOfMeasure,
|
||||||
projectCustomerName: record.project?.customer.name ?? null,
|
projectCustomerName: record.project?.customer.name ?? null,
|
||||||
dueQuantity: record.quantity - record.completedQuantity,
|
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) => {
|
materialRequirements: record.item.bomLines.map((line) => {
|
||||||
const requiredQuantity = line.quantity * record.quantity;
|
const requiredQuantity = line.quantity * record.quantity;
|
||||||
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
|
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() {
|
async function nextWorkOrderNumber() {
|
||||||
const next = (await workOrderModel.count()) + 1;
|
const next = (await workOrderModel.count()) + 1;
|
||||||
return `WO-${String(next).padStart(5, "0")}`;
|
return `WO-${String(next).padStart(5, "0")}`;
|
||||||
@@ -295,6 +493,11 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
status: 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." };
|
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) {
|
if (payload.projectId) {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { id: payload.projectId },
|
where: { id: payload.projectId },
|
||||||
@@ -350,11 +557,51 @@ export async function listManufacturingItemOptions(): Promise<ManufacturingItemO
|
|||||||
name: true,
|
name: true,
|
||||||
type: true,
|
type: true,
|
||||||
unitOfMeasure: true,
|
unitOfMeasure: true,
|
||||||
|
operations: {
|
||||||
|
select: {
|
||||||
|
setupMinutes: true,
|
||||||
|
runMinutesPerUnit: true,
|
||||||
|
moveMinutes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ sku: "asc" }],
|
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[]> {
|
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);
|
const workOrder = await getWorkOrderById(created.id);
|
||||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
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);
|
const workOrder = await getWorkOrderById(workOrderId);
|
||||||
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
|
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;
|
end: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
type: "task" | "project" | "milestone";
|
type: "task" | "project" | "milestone";
|
||||||
|
parentId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
ownerLabel?: string | null;
|
||||||
|
detailHref?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GanttLinkDto {
|
export interface GanttLinkDto {
|
||||||
@@ -14,3 +18,30 @@ export interface GanttLinkDto {
|
|||||||
type: string;
|
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;
|
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 {
|
export interface InventoryItemOptionDto {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
@@ -134,6 +156,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
|
|||||||
notes: string;
|
notes: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
bomLines: InventoryBomLineDto[];
|
bomLines: InventoryBomLineDto[];
|
||||||
|
operations: InventoryItemOperationDto[];
|
||||||
onHandQuantity: number;
|
onHandQuantity: number;
|
||||||
stockBalances: InventoryStockBalanceDto[];
|
stockBalances: InventoryStockBalanceDto[];
|
||||||
recentTransactions: InventoryTransactionDto[];
|
recentTransactions: InventoryTransactionDto[];
|
||||||
@@ -152,4 +175,5 @@ export interface InventoryItemInput {
|
|||||||
defaultPrice: number | null;
|
defaultPrice: number | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
bomLines: InventoryBomLineInput[];
|
bomLines: InventoryBomLineInput[];
|
||||||
|
operations: InventoryItemOperationInput[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD",
|
|||||||
|
|
||||||
export type WorkOrderStatus = (typeof workOrderStatuses)[number];
|
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 {
|
export interface ManufacturingProjectOptionDto {
|
||||||
id: string;
|
id: string;
|
||||||
projectNumber: string;
|
projectNumber: string;
|
||||||
@@ -16,6 +35,8 @@ export interface ManufacturingItemOptionDto {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
unitOfMeasure: string;
|
unitOfMeasure: string;
|
||||||
|
operationCount: number;
|
||||||
|
totalEstimatedMinutesPerUnit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrderSummaryDto {
|
export interface WorkOrderSummaryDto {
|
||||||
@@ -37,9 +58,26 @@ export interface WorkOrderSummaryDto {
|
|||||||
locationId: string;
|
locationId: string;
|
||||||
locationCode: string;
|
locationCode: string;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
operationCount: number;
|
||||||
|
totalPlannedMinutes: number;
|
||||||
updatedAt: string;
|
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 {
|
export interface WorkOrderMaterialRequirementDto {
|
||||||
componentItemId: string;
|
componentItemId: string;
|
||||||
componentSku: string;
|
componentSku: string;
|
||||||
@@ -83,6 +121,7 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
|
|||||||
itemUnitOfMeasure: string;
|
itemUnitOfMeasure: string;
|
||||||
projectCustomerName: string | null;
|
projectCustomerName: string | null;
|
||||||
dueQuantity: number;
|
dueQuantity: number;
|
||||||
|
operations: WorkOrderOperationDto[];
|
||||||
materialRequirements: WorkOrderMaterialRequirementDto[];
|
materialRequirements: WorkOrderMaterialRequirementDto[];
|
||||||
materialIssues: WorkOrderMaterialIssueDto[];
|
materialIssues: WorkOrderMaterialIssueDto[];
|
||||||
completions: WorkOrderCompletionDto[];
|
completions: WorkOrderCompletionDto[];
|
||||||
|
|||||||
Reference in New Issue
Block a user