manufacturing

This commit is contained in:
2026-03-15 11:12:58 -05:00
parent 6644ba2932
commit 0596970b99
25 changed files with 2097 additions and 37 deletions

View File

@@ -19,6 +19,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- sales quotes, sales orders, and purchase orders
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and attachments
- Puppeteer PDF foundation
- single-container Docker deployment
@@ -107,7 +108,7 @@ If implementation changes invalidate those docs, update them in the same change
- Customer-facing and logistics PDFs should continue to use the backend documents module and Puppeteer pipeline
- The landing experience should remain `Dashboard`, not `Overview`, and should evolve as a modular metric-first operational surface
- Projects are a first-class domain that anchors long-running program execution across CRM, sales, inventory, purchasing, shipping, and planning, and future work should continue extending that module rather than scattering project state elsewhere
- Manufacturing should remain a separate future domain for work orders, routings, labor, and shop-floor execution
- Manufacturing is now a first-class domain for work orders and inventory-backed execution, and future work should keep expanding it as a separate subsystem for routings, labor, and shop-floor control
- Planning should remain the scheduling/visibility layer over projects and manufacturing, not a replacement for either
- New top-level modules added to shell navigation should ship with a matching SVG icon, not text-only nav entries
@@ -115,11 +116,11 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are:
1. Manufacturing execution
2. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
3. Sales approvals and document revision history
4. Planning and gantt scheduling with live project/manufacturing data
5. Inventory transfers, reservations, and deeper stock controls
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
2. Sales approvals and document revision history
3. Planning and gantt scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -10,11 +10,15 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Project linkage to sales quotes, sales orders, and shipments for cross-module delivery tracking
- Project list/detail/create/edit workflows and app-shell navigation entry
- Dashboard project widgets for active, at-risk, and overdue program visibility
- Manufacturing foundation with work orders, optional project linkage, work-order attachments, and app-shell navigation entry
- BOM-based manufacturing requirement visibility plus material issue and completion posting through inventory transactions
- Dashboard manufacturing widgets for released, active, and overdue work visibility
### Changed
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- Roadmap and project docs now treat manufacturing execution as the next active priority after the projects foundation slice
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- Roadmap and project docs now treat vendor invoice/supporting-document attachments and broader vendor-side operational depth as the next active priority after the manufacturing foundation slice
## 2026-03-15

View File

@@ -22,6 +22,7 @@ This repository implements the platform foundation milestone:
- branded sales and purchasing PDFs through the shared Puppeteer document pipeline
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, material issue posting, completion posting, attachments, and dashboard visibility
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -57,7 +58,6 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- manufacturing execution
- vendor invoice/supporting-document attachments and broader vendor-side operational depth
- sales approvals and document revision history
- planning and gantt scheduling with live project/manufacturing data

View File

@@ -21,6 +21,7 @@ Current foundation scope includes:
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, material issue posting, completion posting, and work-order attachments
- file storage and PDF rendering
## Product Map
@@ -33,20 +34,20 @@ Current completed foundation areas:
- sales and purchasing foundation
- shipping foundation
- projects foundation
- manufacturing foundation
- branding, attachments, auth/RBAC, and PDF infrastructure
Planned cross-module execution areas:
- manufacturing execution
- planning and gantt scheduling
Near-term priorities:
1. Manufacturing execution
2. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
3. Sales approvals and revision history
4. Planning and gantt scheduling with live project/manufacturing data
5. Inventory transfers, reservations, and deeper stock controls
1. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
2. Sales approvals and revision history
3. Planning and gantt scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics
Revisit / deferred items:
@@ -64,6 +65,7 @@ Dashboard direction:
- future additions should emphasize relevant metrics, next actions, alerts, and workflow shortcuts
- richer recent-activity widgets and exception queues are a planned QOL follow-up, not a separate landing-page redesign
- projects now feed dashboard widgets for active programs, overdue work, and risk
- manufacturing now feeds dashboard widgets for released work, overdue orders, and execution load
- future project widgets should deepen milestones, shortages, and shipment readiness
Navigation direction:
@@ -92,12 +94,16 @@ Next expansion areas:
## Manufacturing Direction
Manufacturing should remain a separate execution subsystem rather than being collapsed into Projects.
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.
Planned interactions:
Current interactions:
- Projects: manufacturing orders may belong to a project, but projects remain the higher-level long-running record
- Inventory: manufacturing consumes components and produces stock
- Inventory: manufacturing consumes components and produces stock through real issue/receipt transactions
- Dashboard: manufacturing now contributes released/open/overdue load widgets
Next expansion areas:
- Purchasing: shortages and buyout demand should surface from manufacturing execution
- Shipping: completed manufacturing should feed shipment readiness
- Planning: manufacturing orders, routings, and work centers should drive capacity and schedule views
@@ -308,15 +314,16 @@ As of March 14, 2026, the latest committed domain migrations include:
- sales totals and commercial fields
- shipping foundation
- projects foundation
- manufacturing foundation
Recent roadmap-driving migrations should always be applied before validating new CRM, inventory, sales, shipping, or purchasing features in a running environment.
## UI Notes
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, settings, and planning modules from the same app shell.
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes Dashboard, CRM, inventory, sales, shipping, projects, manufacturing, settings, and planning modules from the same app shell.
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, manufacturing, and audit data.
- The dashboard should continue evolving as a modular metric board for future purchasing, shipping, planning, and audit data.
- The client build still emits a Vite chunk-size warning because the app has not been code-split yet.
## PDF Generation

View File

@@ -42,6 +42,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Logistics attachments directly on shipment records
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
- Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, material issue posting, completion posting, and work-order attachments
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
@@ -58,14 +59,16 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, vendor-side attachment handling, or deeper carrier integration
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
- The new projects domain is foundational but still needs milestones, project rollups, and deeper inventory/purchasing/manufacturing tie-ins
- The new manufacturing domain is foundational but still needs routings, labor capture, work-center views, and capacity-aware planning tie-ins
## Dashboard Plan
- Keep `Dashboard` as the primary landing surface for operators
- Expand it by modular panels rather than redesigning it for each new feature phase
- Prefer metric cards, exception queues, action shortcuts, and status summaries over static descriptive content
- Add future widgets for purchasing, shipping exceptions, inventory shortages, manufacturing load, and audit/system health
- Add future widgets for purchasing, shipping exceptions, inventory shortages, planning readiness, and audit/system health
- Continue expanding the new project widgets into milestone, blockage, and shipment-readiness views instead of creating a separate landing area
- Continue expanding the new manufacturing widgets into shortage, routing, and bottleneck views instead of creating a separate landing area
- Treat dashboard modules as upgradeable blocks that can be reordered or expanded without disturbing the shell
## Planned feature phases
@@ -176,6 +179,14 @@ QOL subfeatures:
### Phase 6: Manufacturing execution
Foundation slice shipped:
- Work orders tied to manufactured or assembly items, with optional project linkage
- BOM-based material requirement visibility from the work-order record
- Material issue posting that creates real inventory issue transactions
- Production completion posting that creates finished-goods receipt transactions
- Work-order list/detail/create/edit flows, attachments, and dashboard visibility
- Work orders tied to projects, sales demand, or internal build demand
- Routing/work-center structure for manufacturing steps and handoffs
- Material issue, consumption, completion, and WIP tracking
@@ -246,7 +257,6 @@ QOL subfeatures:
- Audit-trail depth is still thin outside the current record/update flows
- Some generated document and workflow screens still need additional polish for dense, keyboard-efficient operational use
- Dashboard cards now use live data, but richer recent-activity widgets and exception queues are still deferred
- Manufacturing execution is not yet separated cleanly from planning/scheduling in the current future-state docs and implementation
## Cross-cutting improvements
@@ -259,8 +269,8 @@ QOL subfeatures:
## Near-term priority order
1. Manufacturing execution
2. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
3. Sales approvals and document revision history
4. Planning and scheduling with live project/manufacturing data
5. Inventory transfers, reservations, and deeper stock controls
1. Vendor invoice/supporting-document attachments and broader vendor-side operational depth
2. Sales approvals and document revision history
3. Planning and scheduling with live project/manufacturing data
4. Inventory transfers, reservations, and deeper stock controls
5. Broader audit-trail coverage and operational diagnostics

View File

@@ -130,7 +130,7 @@ When you publish a new image:
Because MRP Codex runs `prisma migrate deploy` during startup, committed migrations are applied automatically before the app launches.
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, and the new projects domain. Let the container complete startup migrations before testing new screens.
This is especially important now that recent releases added CRM expansion, inventory transactions, sales and purchasing documents, shipping/logistics documents, the inventory `defaultPrice` field, purchasable-only purchase-order item selection, the new projects domain, and manufacturing work orders. Let the container complete startup migrations before testing new screens.
## Backup guidance

View File

@@ -16,6 +16,7 @@ const links = [
{ to: "/purchasing/orders", label: "Purchase Orders", icon: <PurchaseOrderIcon /> },
{ to: "/shipping/shipments", label: "Shipments", icon: <ShipmentIcon /> },
{ to: "/projects", label: "Projects", icon: <ProjectsIcon /> },
{ to: "/manufacturing/work-orders", label: "Manufacturing", icon: <ManufacturingIcon /> },
{ to: "/planning/gantt", label: "Gantt", icon: <GanttIcon /> },
];
@@ -170,6 +171,18 @@ function ProjectsIcon() {
);
}
function ManufacturingIcon() {
return (
<NavIcon>
<circle cx="8" cy="16" r="2" />
<circle cx="16" cy="16" r="2" />
<path d="M8 14V8l4-2 4 2v6" />
<path d="M12 10h6" />
<path d="M18 8v4" />
</NavIcon>
);
}
export function AppShell() {
const { user, logout } = useAuth();

View File

@@ -33,6 +33,16 @@ import type {
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
import type {
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
} from "@mrp/shared";
import type {
ProjectCustomerOptionDto,
ProjectDetailDto,
@@ -444,6 +454,54 @@ export const api = {
token
);
},
getManufacturingItemOptions(token: string) {
return request<ManufacturingItemOptionDto[]>("/api/v1/manufacturing/items/options", undefined, token);
},
getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
},
getWorkOrders(token: string, filters?: { q?: string; status?: WorkOrderStatus; projectId?: string; itemId?: string }) {
return request<WorkOrderSummaryDto[]>(
`/api/v1/manufacturing/work-orders${buildQueryString({
q: filters?.q,
status: filters?.status,
projectId: filters?.projectId,
itemId: filters?.itemId,
})}`,
undefined,
token
);
},
getWorkOrder(token: string, workOrderId: string) {
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, undefined, token);
},
createWorkOrder(token: string, payload: WorkOrderInput) {
return request<WorkOrderDetailDto>("/api/v1/manufacturing/work-orders", { method: "POST", body: JSON.stringify(payload) }, token);
},
updateWorkOrder(token: string, workOrderId: string, payload: WorkOrderInput) {
return request<WorkOrderDetailDto>(`/api/v1/manufacturing/work-orders/${workOrderId}`, { method: "PUT", body: JSON.stringify(payload) }, token);
},
updateWorkOrderStatus(token: string, workOrderId: string, status: WorkOrderStatus) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/status`,
{ method: "PATCH", body: JSON.stringify({ status }) },
token
);
},
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
recordWorkOrderCompletion(token: string, workOrderId: string, payload: WorkOrderCompletionInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/completions`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
getGanttDemo(token: string) {
return request<{ tasks: GanttTaskDto[]; links: GanttLinkDto[] }>("/api/v1/gantt/demo", undefined, token);
},

View File

@@ -18,6 +18,9 @@ import { GanttPage } from "./modules/gantt/GanttPage";
import { InventoryDetailPage } from "./modules/inventory/InventoryDetailPage";
import { InventoryFormPage } from "./modules/inventory/InventoryFormPage";
import { InventoryItemsPage } from "./modules/inventory/InventoryItemsPage";
import { ManufacturingPage } from "./modules/manufacturing/ManufacturingPage";
import { WorkOrderDetailPage } from "./modules/manufacturing/WorkOrderDetailPage";
import { WorkOrderFormPage } from "./modules/manufacturing/WorkOrderFormPage";
import { PurchaseDetailPage } from "./modules/purchasing/PurchaseDetailPage";
import { PurchaseFormPage } from "./modules/purchasing/PurchaseFormPage";
import { PurchaseListPage } from "./modules/purchasing/PurchaseListPage";
@@ -76,6 +79,13 @@ const router = createBrowserRouter([
{ path: "/projects/:projectId", element: <ProjectDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingRead]} />,
children: [
{ path: "/manufacturing/work-orders", element: <ManufacturingPage /> },
{ path: "/manufacturing/work-orders/:workOrderId", element: <WorkOrderDetailPage /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.read"]} />,
children: [
@@ -115,6 +125,13 @@ const router = createBrowserRouter([
{ path: "/projects/:projectId/edit", element: <ProjectFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={[permissions.manufacturingWrite]} />,
children: [
{ path: "/manufacturing/work-orders/new", element: <WorkOrderFormPage mode="create" /> },
{ path: "/manufacturing/work-orders/:workOrderId/edit", element: <WorkOrderFormPage mode="edit" /> },
],
},
{
element: <ProtectedRoute requiredPermissions={["purchasing.write"]} />,
children: [

View File

@@ -10,6 +10,7 @@ interface DashboardSnapshot {
vendors: Awaited<ReturnType<typeof api.getVendors>> | null;
items: Awaited<ReturnType<typeof api.getInventoryItems>> | null;
warehouses: Awaited<ReturnType<typeof api.getWarehouses>> | null;
workOrders: Awaited<ReturnType<typeof api.getWorkOrders>> | null;
quotes: Awaited<ReturnType<typeof api.getQuotes>> | null;
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
@@ -51,6 +52,7 @@ export function DashboardPage() {
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canWriteManufacturing = hasPermission(user?.permissions, permissions.manufacturingWrite);
const canWriteProjects = hasPermission(user?.permissions, permissions.projectsWrite);
useEffect(() => {
@@ -67,6 +69,7 @@ export function DashboardPage() {
const canReadCrm = hasPermission(user.permissions, permissions.crmRead);
const canReadInventory = hasPermission(user.permissions, permissions.inventoryRead);
const canReadManufacturing = hasPermission(user.permissions, permissions.manufacturingRead);
const canReadSales = hasPermission(user.permissions, permissions.salesRead);
const canReadShipping = hasPermission(user.permissions, permissions.shippingRead);
const canReadProjects = hasPermission(user.permissions, permissions.projectsRead);
@@ -77,6 +80,7 @@ export function DashboardPage() {
canReadCrm ? api.getVendors(authToken) : Promise.resolve(null),
canReadInventory ? api.getInventoryItems(authToken) : Promise.resolve(null),
canReadInventory ? api.getWarehouses(authToken) : Promise.resolve(null),
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
@@ -98,10 +102,11 @@ export function DashboardPage() {
vendors: results[1].status === "fulfilled" ? results[1].value : null,
items: results[2].status === "fulfilled" ? results[2].value : null,
warehouses: results[3].status === "fulfilled" ? results[3].value : null,
quotes: results[4].status === "fulfilled" ? results[4].value : null,
orders: results[5].status === "fulfilled" ? results[5].value : null,
shipments: results[6].status === "fulfilled" ? results[6].value : null,
projects: results[7].status === "fulfilled" ? results[7].value : null,
workOrders: results[4].status === "fulfilled" ? results[4].value : null,
quotes: results[5].status === "fulfilled" ? results[5].value : null,
orders: results[6].status === "fulfilled" ? results[6].value : null,
shipments: results[7].status === "fulfilled" ? results[7].value : null,
projects: results[8].status === "fulfilled" ? results[8].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
@@ -126,6 +131,7 @@ export function DashboardPage() {
const vendors = snapshot?.vendors ?? [];
const items = snapshot?.items ?? [];
const warehouses = snapshot?.warehouses ?? [];
const workOrders = snapshot?.workOrders ?? [];
const quotes = snapshot?.quotes ?? [];
const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? [];
@@ -134,6 +140,7 @@ export function DashboardPage() {
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
snapshot?.items !== null || snapshot?.warehouses !== null,
snapshot?.workOrders !== null,
snapshot?.quotes !== null || snapshot?.orders !== null,
snapshot?.shipments !== null,
snapshot?.projects !== null,
@@ -152,6 +159,11 @@ export function DashboardPage() {
const warehouseCount = warehouses.length;
const locationCount = sumNumber(warehouses.map((warehouse) => warehouse.locationCount));
const workOrderCount = workOrders.length;
const activeWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED" || workOrder.status === "IN_PROGRESS" || workOrder.status === "ON_HOLD").length;
const releasedWorkOrderCount = workOrders.filter((workOrder) => workOrder.status === "RELEASED").length;
const overdueWorkOrderCount = workOrders.filter((workOrder) => workOrder.dueDate && workOrder.status !== "COMPLETE" && workOrder.status !== "CANCELLED" && new Date(workOrder.dueDate).getTime() < Date.now()).length;
const quoteCount = quotes.length;
const orderCount = orders.length;
const draftQuoteCount = quotes.filter((quote) => quote.status === "DRAFT").length;
@@ -180,6 +192,7 @@ export function DashboardPage() {
...vendors.map((vendor) => vendor.updatedAt),
...items.map((item) => item.updatedAt),
...warehouses.map((warehouse) => warehouse.updatedAt),
...workOrders.map((workOrder) => workOrder.updatedAt),
...quotes.map((quote) => quote.updatedAt),
...orders.map((order) => order.updatedAt),
...shipments.map((shipment) => shipment.updatedAt),
@@ -207,6 +220,15 @@ export function DashboardPage() {
: "Inventory metrics are permission-gated.",
tone: "border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
},
{
label: "Manufacturing Load",
value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access",
detail:
snapshot?.workOrders !== null
? `${releasedWorkOrderCount} released and ${overdueWorkOrderCount} overdue`
: "Manufacturing metrics are permission-gated.",
tone: "border-indigo-400/30 bg-indigo-500/12 text-indigo-700 dark:text-indigo-300",
},
{
label: "Commercial Value",
value: snapshot?.quotes !== null || snapshot?.orders !== null ? formatCurrency(quoteValue + orderValue) : "No access",
@@ -271,6 +293,23 @@ export function DashboardPage() {
{ label: "Open warehouses", to: "/inventory/warehouses" },
],
},
{
title: "Manufacturing",
eyebrow: "Execution Load",
summary:
snapshot?.workOrders !== null
? "Work orders, released load, and overdue build pressure are now visible from the dashboard."
: "Manufacturing read permission is required to surface work-order metrics here.",
metrics: [
{ label: "Open work", value: snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access" },
{ label: "Released", value: snapshot?.workOrders !== null ? `${releasedWorkOrderCount}` : "No access" },
{ label: "Overdue", value: snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access" },
],
links: [
{ label: "Open work orders", to: "/manufacturing/work-orders" },
...(canWriteManufacturing ? [{ label: "New work order", to: "/manufacturing/work-orders/new" }] : []),
],
},
{
title: "Sales",
eyebrow: "Revenue Flow",
@@ -327,7 +366,6 @@ export function DashboardPage() {
const futureModules = [
"Vendor invoice attachments and supplier exception queues",
"Stock transfers, allocations, and cycle counts",
"Manufacturing work orders, routings, and bottleneck metrics",
"Planning timeline, milestones, and dependency views",
"Audit trails, diagnostics, and system health checks",
];
@@ -350,8 +388,8 @@ export function DashboardPage() {
</div>
</div>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted">
This landing page now reads directly from live CRM, inventory, sales, shipping, and project data. It is intentionally modular so future
purchasing, manufacturing, and audit slices can slot into the same command surface without a redesign.
This landing page now reads directly from live CRM, inventory, manufacturing, sales, shipping, and project data. It is intentionally
modular so future purchasing, planning, and audit slices can slot into the same command surface without a redesign.
</p>
<div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-line/70 bg-surface/80 px-2 py-2">
@@ -380,6 +418,9 @@ export function DashboardPage() {
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/projects">
Open projects
</Link>
<Link className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text" to="/manufacturing/work-orders">
Open manufacturing
</Link>
</div>
{error ? <div className="mt-4 rounded-2xl border border-amber-400/30 bg-amber-500/12 px-2 py-2 text-sm text-amber-700 dark:text-amber-300">{error}</div> : null}
</div>
@@ -396,7 +437,7 @@ export function DashboardPage() {
</div>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-6">
{metricCards.map((card) => (
<article key={card.label} 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">{card.label}</p>
@@ -408,7 +449,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-6">
{modulePanels.map((panel) => (
<article key={panel.title} 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">{panel.eyebrow}</p>
@@ -432,7 +473,7 @@ export function DashboardPage() {
</article>
))}
</section>
<section className="grid gap-3 xl:grid-cols-4">
<section className="grid gap-3 xl:grid-cols-5">
<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">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>
@@ -469,6 +510,24 @@ export function DashboardPage() {
</div>
</div>
</article>
<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 Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Build execution and due-date pressure</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Total work orders</span>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${workOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Active queue</span>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${activeWorkOrderCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Overdue</span>
<span className="font-semibold text-text">{snapshot?.workOrders !== null ? `${overdueWorkOrderCount}` : "No access"}</span>
</div>
</div>
</article>
<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">Project Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Program status and delivery pressure</h4>

View File

@@ -0,0 +1,5 @@
import { WorkOrderListPage } from "./WorkOrderListPage";
export function ManufacturingPage() {
return <WorkOrderListPage />;
}

View File

@@ -0,0 +1,338 @@
import { permissions } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyCompletionInput, emptyMaterialIssueInput, workOrderStatusOptions } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
export function WorkOrderDetailPage() {
const { token, user } = useAuth();
const { workOrderId } = useParams();
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
const [status, setStatus] = useState("Loading work order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
useEffect(() => {
if (!token || !workOrderId) {
return;
}
api.getWorkOrder(token, workOrderId)
.then((nextWorkOrder) => {
setWorkOrder(nextWorkOrder);
setIssueForm({
...emptyMaterialIssueInput,
warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId,
});
setCompletionForm({
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
setStatus(message);
});
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token, workOrderId]);
const filteredLocationOptions = useMemo(
() => locationOptions.filter((option) => option.warehouseId === issueForm.warehouseId),
[issueForm.warehouseId, locationOptions]
);
async function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!token || !workOrder) {
return;
}
setIsUpdatingStatus(true);
setStatus("Updating work-order status...");
try {
const nextWorkOrder = await api.updateWorkOrderStatus(token, workOrder.id, nextStatus);
setWorkOrder(nextWorkOrder);
setStatus("Work-order status updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update work-order status.";
setStatus(message);
} finally {
setIsUpdatingStatus(false);
}
}
async function handleIssueSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !workOrder) {
return;
}
setIsPostingIssue(true);
setStatus("Posting material issue...");
try {
const nextWorkOrder = await api.issueWorkOrderMaterial(token, workOrder.id, issueForm);
setWorkOrder(nextWorkOrder);
setIssueForm({
...emptyMaterialIssueInput,
warehouseId: nextWorkOrder.warehouseId,
locationId: nextWorkOrder.locationId,
});
setStatus("Material issue posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post material issue.";
setStatus(message);
} finally {
setIsPostingIssue(false);
}
}
async function handleCompletionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !workOrder) {
return;
}
setIsPostingCompletion(true);
setStatus("Posting completion...");
try {
const nextWorkOrder = await api.recordWorkOrderCompletion(token, workOrder.id, completionForm);
setWorkOrder(nextWorkOrder);
setCompletionForm({
...emptyCompletionInput,
quantity: Math.max(nextWorkOrder.dueQuantity, 1),
});
setStatus("Completion posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post completion.";
setStatus(message);
} finally {
setIsPostingCompletion(false);
}
}
if (!workOrder) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Work Order</p>
<h3 className="mt-2 text-xl font-bold text-text">{workOrder.workOrderNumber}</h3>
<p className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</p>
<div className="mt-3"><WorkOrderStatusBadge status={workOrder.status} /></div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
<Link to={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link>
{canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null}
</div>
</div>
</div>
{canManage ? (
<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-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Release, hold, or close administrative status from the work-order record.</p>
</div>
<div className="flex flex-wrap gap-2">
{workOrderStatusOptions.map((option) => (
<button key={option.value} type="button" onClick={() => handleStatusChange(option.value)} disabled={isUpdatingStatus || workOrder.status === option.value} className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">
{option.label}
</button>
))}
</div>
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-5">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned</p><div className="mt-2 text-base font-bold text-text">{workOrder.quantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Completed</p><div className="mt-2 text-base font-bold text-text">{workOrder.completedQuantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Remaining</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueQuantity}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<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">Execution Context</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build item</dt><dd className="mt-1 text-sm text-text">{workOrder.itemSku} - {workOrder.itemName}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "Not linked"}</dd></div>
</dl>
</article>
<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">Work Instructions</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{workOrder.notes || "No work-order notes recorded."}</p>
</article>
</div>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form onSubmit={handleIssueSubmit} className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Material Issue</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Component</span>
<select value={issueForm.componentItemId} onChange={(event) => setIssueForm((current) => ({ ...current, componentItemId: 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">
<option value="">Select component</option>
{workOrder.materialRequirements.map((requirement) => (
<option key={requirement.componentItemId} value={requirement.componentItemId}>{requirement.componentSku} - {requirement.componentName}</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
<select value={issueForm.warehouseId} onChange={(event) => setIssueForm((current) => ({ ...current, warehouseId: event.target.value, locationId: "" }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{[...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()].map((option) => (
<option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
<select value={issueForm.locationId} onChange={(event) => setIssueForm((current) => ({ ...current, locationId: 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">
<option value="">Select location</option>
{filteredLocationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={issueForm.quantity} onChange={(event) => setIssueForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={issueForm.notes} onChange={(event) => setIssueForm((current) => ({ ...current, notes: 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>
<button type="submit" disabled={isPostingIssue} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isPostingIssue ? "Posting issue..." : "Post material issue"}
</button>
</div>
</form>
<form onSubmit={handleCompletionSubmit} 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">Production Completion</p>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={completionForm.quantity} onChange={(event) => setCompletionForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={completionForm.notes} onChange={(event) => setCompletionForm((current) => ({ ...current, notes: 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>
<div className="rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">Finished goods receipt posts back to {workOrder.warehouseCode} / {workOrder.locationCode}.</div>
<button type="submit" disabled={isPostingCompletion} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isPostingCompletion ? "Posting completion..." : "Post completion"}
</button>
</div>
</form>
</section>
) : null}
<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">Material Requirements</p>
{workOrder.materialRequirements.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 build item does not currently have BOM material requirements.</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">Component</th>
<th className="px-3 py-3">Per</th>
<th className="px-3 py-3">Required</th>
<th className="px-3 py-3">Issued</th>
<th className="px-3 py-3">Remaining</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrder.materialRequirements.map((requirement) => (
<tr key={requirement.componentItemId} className="bg-surface/70">
<td className="px-3 py-3"><div className="font-semibold text-text">{requirement.componentSku}</div><div className="mt-1 text-xs text-muted">{requirement.componentName}</div></td>
<td className="px-3 py-3 text-text">{requirement.quantityPer} {requirement.unitOfMeasure}</td>
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="grid gap-3 xl:grid-cols-2">
<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">Issue History</p>
{workOrder.materialIssues.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 material issues have been posted yet.</div>
) : (
<div className="mt-5 space-y-3">
{workOrder.materialIssues.map((issue) => (
<div key={issue.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{issue.componentSku} - {issue.componentName}</div>
<div className="mt-1 text-xs text-muted">{issue.warehouseCode} / {issue.locationCode} · {issue.createdByName}</div>
</div>
<div className="text-sm font-semibold text-text">{issue.quantity}</div>
</div>
<div className="mt-2 text-xs text-muted">{new Date(issue.createdAt).toLocaleString()}</div>
<div className="mt-2 text-sm text-text">{issue.notes || "No notes recorded."}</div>
</div>
))}
</div>
)}
</article>
<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">Completion History</p>
{workOrder.completions.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 production completions have been posted yet.</div>
) : (
<div className="mt-5 space-y-3">
{workOrder.completions.map((completion) => (
<div key={completion.id} className="rounded-3xl border border-line/70 bg-page/60 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="font-semibold text-text">{completion.quantity} completed</div>
<div className="text-xs text-muted">{completion.createdByName}</div>
</div>
<div className="mt-2 text-xs text-muted">{new Date(completion.createdAt).toLocaleString()}</div>
<div className="mt-2 text-sm text-text">{completion.notes || "No notes recorded."}</div>
</div>
))}
</div>
)}
</article>
</section>
<FileAttachmentsPanel
ownerType="WORK_ORDER"
ownerId={workOrder.id}
eyebrow="Manufacturing Documents"
title="Work-order files"
description="Store travelers, build instructions, inspection records, and support documents directly on the work order."
emptyMessage="No manufacturing attachments have been uploaded for this work order yet."
/>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);
}

View File

@@ -0,0 +1,268 @@
import type {
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderInput,
} from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyWorkOrderInput, workOrderStatusOptions } from "./config";
export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { token } = useAuth();
const navigate = useNavigate();
const { workOrderId } = useParams();
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [itemSearchTerm, setItemSearchTerm] = useState("");
const [projectSearchTerm, setProjectSearchTerm] = useState("");
const [itemPickerOpen, setItemPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new work order." : "Loading work order...");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then(setProjectOptions).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [token]);
useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) {
return;
}
api.getWorkOrder(token, workOrderId)
.then((workOrder) => {
setForm({
itemId: workOrder.itemId,
projectId: workOrder.projectId,
status: workOrder.status,
quantity: workOrder.quantity,
warehouseId: workOrder.warehouseId,
locationId: workOrder.locationId,
dueDate: workOrder.dueDate,
notes: workOrder.notes,
});
setItemSearchTerm(`${workOrder.itemSku} - ${workOrder.itemName}`);
setProjectSearchTerm(workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "");
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work order.";
setStatus(message);
});
}, [mode, token, workOrderId]);
const warehouseOptions = useMemo(
() => [...new Map(locationOptions.map((option) => [option.warehouseId, option])).values()],
[locationOptions]
);
const filteredLocationOptions = useMemo(
() => locationOptions.filter((option) => option.warehouseId === form.warehouseId),
[form.warehouseId, locationOptions]
);
function updateField<Key extends keyof WorkOrderInput>(key: Key, value: WorkOrderInput[Key]) {
setForm((current) => ({
...current,
[key]: value,
...(key === "warehouseId" ? { locationId: "" } : {}),
}));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token) {
return;
}
setIsSaving(true);
setStatus("Saving work order...");
try {
const saved = mode === "create" ? await api.createWorkOrder(token, form) : await api.updateWorkOrder(token, workOrderId ?? "", form);
navigate(`/manufacturing/work-orders/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save work order.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
<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 Editor</p>
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Work Order" : "Edit Work Order"}</h3>
<p className="mt-2 max-w-2xl text-sm text-muted">Create a build record for a manufactured item, assign it to a project when needed, and define where completed output should post.</p>
</div>
<Link to={mode === "create" ? "/manufacturing/work-orders" : `/manufacturing/work-orders/${workOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Cancel
</Link>
</div>
</section>
<section className="space-y-4 rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Build Item</span>
<div className="relative">
<input
value={itemSearchTerm}
onChange={(event) => {
setItemSearchTerm(event.target.value);
updateField("itemId", "");
setItemPickerOpen(true);
}}
onFocus={() => setItemPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setItemPickerOpen(false);
const selected = itemOptions.find((option) => option.id === form.itemId);
if (selected) {
setItemSearchTerm(`${selected.sku} - ${selected.name}`);
}
}, 120);
}}
placeholder="Search manufactured item"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{itemPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{itemOptions
.filter((option) => {
const query = itemSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.sku.toLowerCase().includes(query) || option.name.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button key={option.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("itemId", option.id);
setItemSearchTerm(`${option.sku} - ${option.name}`);
setItemPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.type}</div>
</button>
))}
</div>
) : null}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Project</span>
<div className="relative">
<input
value={projectSearchTerm}
onChange={(event) => {
setProjectSearchTerm(event.target.value);
updateField("projectId", null);
setProjectPickerOpen(true);
}}
onFocus={() => setProjectPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setProjectPickerOpen(false);
const selected = projectOptions.find((option) => option.id === form.projectId);
if (selected) {
setProjectSearchTerm(`${selected.projectNumber} - ${selected.name}`);
}
}, 120);
}}
placeholder="Search linked project (optional)"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{projectPickerOpen ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("projectId", null);
setProjectSearchTerm("");
setProjectPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70">
<div className="font-semibold text-text">No linked project</div>
</button>
{projectOptions
.filter((option) => {
const query = projectSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return option.projectNumber.toLowerCase().includes(query) || option.name.toLowerCase().includes(query) || option.customerName.toLowerCase().includes(query);
})
.slice(0, 12)
.map((option) => (
<button key={option.id} type="button" onMouseDown={(event) => {
event.preventDefault();
updateField("projectId", option.id);
setProjectSearchTerm(`${option.projectNumber} - ${option.name}`);
setProjectPickerOpen(false);
}} className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70">
<div className="font-semibold text-text">{option.projectNumber}</div>
<div className="mt-1 text-xs text-muted">{option.name} · {option.customerName}</div>
</button>
))}
</div>
) : null}
</div>
</label>
</div>
<div className="grid gap-3 xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select value={form.status} onChange={(event) => updateField("status", event.target.value as WorkOrderInput["status"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{workOrderStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={form.quantity} onChange={(event) => updateField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Warehouse</span>
<select value={form.warehouseId} onChange={(event) => updateField("warehouseId", 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">
<option value="">Select warehouse</option>
{warehouseOptions.map((option) => <option key={option.warehouseId} value={option.warehouseId}>{option.warehouseCode} - {option.warehouseName}</option>)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Location</span>
<select value={form.locationId} onChange={(event) => updateField("locationId", 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">
<option value="">Select location</option>
{filteredLocationOptions.map((option) => <option key={option.locationId} value={option.locationId}>{option.locationCode} - {option.locationName}</option>)}
</select>
</label>
</div>
<label className="block max-w-sm">
<span className="mb-2 block text-sm font-semibold text-text">Due date</span>
<input type="date" value={form.dueDate ? form.dueDate.slice(0, 10) : ""} onChange={(event) => updateField("dueDate", event.target.value ? new Date(event.target.value).toISOString() : null)} 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">Work instructions / notes</span>
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={5} className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 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..." : mode === "create" ? "Create work order" : "Save changes"}
</button>
</div>
</section>
</form>
);
}

View File

@@ -0,0 +1,107 @@
import { permissions } from "@mrp/shared";
import type { WorkOrderStatus, WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { workOrderStatusFilters } from "./config";
import { WorkOrderStatusBadge } from "./WorkOrderStatusBadge";
export function WorkOrderListPage() {
const { token, user } = useAuth();
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"ALL" | WorkOrderStatus>("ALL");
const [status, setStatus] = useState("Loading work orders...");
const canManage = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
useEffect(() => {
if (!token) {
return;
}
setStatus("Loading work orders...");
api.getWorkOrders(token, { q: query || undefined, status: statusFilter === "ALL" ? undefined : statusFilter })
.then((nextWorkOrders) => {
setWorkOrders(nextWorkOrders);
setStatus(nextWorkOrders.length === 0 ? "No work orders matched the current filters." : `${nextWorkOrders.length} work order(s) loaded.`);
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load work orders.";
setStatus(message);
});
}, [query, statusFilter, token]);
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manufacturing</p>
<h3 className="mt-2 text-xl font-bold text-text">Work Orders</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Release and execute build work against manufactured or assembly inventory items, with project linkage and real inventory posting.</p>
</div>
{canManage ? (
<Link to="/manufacturing/work-orders/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">
New work order
</Link>
) : null}
</div>
</div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px]">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Search</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search work order, item, or project" 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">Status</span>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "ALL" | WorkOrderStatus)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
{workOrderStatusFilters.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">{status}</div>
</section>
{workOrders.length === 0 ? (
<div className="rounded-[28px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">No work orders are available yet.</div>
) : (
<div className="overflow-hidden rounded-[28px] border border-line/70 bg-surface/90 shadow-panel">
<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">Work Order</th>
<th className="px-3 py-3">Item</th>
<th className="px-3 py-3">Project</th>
<th className="px-3 py-3">Status</th>
<th className="px-3 py-3">Qty</th>
<th className="px-3 py-3">Location</th>
<th className="px-3 py-3">Due</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70">
{workOrders.map((workOrder) => (
<tr key={workOrder.id} className="bg-surface/70 transition hover:bg-page/60">
<td className="px-3 py-3 align-top">
<Link to={`/manufacturing/work-orders/${workOrder.id}`} className="font-semibold text-text hover:text-brand">{workOrder.workOrderNumber}</Link>
</td>
<td className="px-3 py-3 align-top">
<div className="font-semibold text-text">{workOrder.itemSku}</div>
<div className="mt-1 text-xs text-muted">{workOrder.itemName}</div>
</td>
<td className="px-3 py-3 align-top text-text">{workOrder.projectNumber ? `${workOrder.projectNumber} - ${workOrder.projectName}` : "Unlinked"}</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.warehouseCode} / {workOrder.locationCode}</td>
<td className="px-3 py-3 align-top text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,7 @@
import type { WorkOrderStatus } from "@mrp/shared";
import { workOrderStatusPalette } from "./config";
export function WorkOrderStatusBadge({ status }: { status: WorkOrderStatus }) {
return <span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${workOrderStatusPalette[status]}`}>{status.replace("_", " ")}</span>;
}

View File

@@ -0,0 +1,48 @@
import type { WorkOrderCompletionInput, WorkOrderInput, WorkOrderMaterialIssueInput, WorkOrderStatus } from "@mrp/shared";
export const workOrderStatusOptions: Array<{ value: WorkOrderStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "RELEASED", label: "Released" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "ON_HOLD", label: "On Hold" },
{ value: "COMPLETE", label: "Complete" },
{ value: "CANCELLED", label: "Cancelled" },
];
export const workOrderStatusFilters: Array<{ value: "ALL" | WorkOrderStatus; label: string }> = [
{ value: "ALL", label: "All statuses" },
...workOrderStatusOptions,
];
export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
DRAFT: "border border-slate-400/30 bg-slate-500/12 text-slate-700 dark:text-slate-300",
RELEASED: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
IN_PROGRESS: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ON_HOLD: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
COMPLETE: "border border-brand/30 bg-brand/10 text-brand",
CANCELLED: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
};
export const emptyWorkOrderInput: WorkOrderInput = {
itemId: "",
projectId: null,
status: "DRAFT",
quantity: 1,
warehouseId: "",
locationId: "",
dueDate: null,
notes: "",
};
export const emptyMaterialIssueInput: WorkOrderMaterialIssueInput = {
componentItemId: "",
warehouseId: "",
locationId: "",
quantity: 1,
notes: "",
};
export const emptyCompletionInput: WorkOrderCompletionInput = {
quantity: 1,
notes: "",
};

View File

@@ -0,0 +1,76 @@
-- CreateTable
CREATE TABLE "WorkOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderNumber" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"projectId" TEXT,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"completedQuantity" INTEGER NOT NULL DEFAULT 0,
"dueDate" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrder_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrder_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderMaterialIssue" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"componentItemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderMaterialIssue_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderMaterialIssue_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "WorkOrderCompletion" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderCompletion_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderCompletion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkOrder_workOrderNumber_key" ON "WorkOrder"("workOrderNumber");
-- CreateIndex
CREATE INDEX "WorkOrder_itemId_createdAt_idx" ON "WorkOrder"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrder_projectId_dueDate_idx" ON "WorkOrder"("projectId", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_status_dueDate_idx" ON "WorkOrder"("status", "dueDate");
-- CreateIndex
CREATE INDEX "WorkOrder_warehouseId_createdAt_idx" ON "WorkOrder"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_workOrderId_createdAt_idx" ON "WorkOrderMaterialIssue"("workOrderId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderMaterialIssue_componentItemId_createdAt_idx" ON "WorkOrderMaterialIssue"("componentItemId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderCompletion_workOrderId_createdAt_idx" ON "WorkOrderCompletion"("workOrderId", "createdAt");

View File

@@ -22,6 +22,8 @@ model User {
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
}
model Role {
@@ -125,6 +127,8 @@ model InventoryItem {
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
purchaseOrderLines PurchaseOrderLine[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
}
model Warehouse {
@@ -137,6 +141,8 @@ model Warehouse {
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
}
model Customer {
@@ -203,6 +209,8 @@ model WarehouseLocation {
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[]
@@unique([warehouseId, code])
@@index([warehouseId])
@@ -399,12 +407,75 @@ model Project {
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
@@index([customerId, createdAt])
@@index([ownerId, dueDate])
@@index([status, priority])
}
model WorkOrder {
id String @id @default(cuid())
workOrderNumber String @unique
itemId String
projectId String?
warehouseId String
locationId String
status String
quantity Int
completedQuantity Int @default(0)
dueDate DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
@@index([itemId, createdAt])
@@index([projectId, dueDate])
@@index([status, dueDate])
@@index([warehouseId, createdAt])
}
model WorkOrderMaterialIssue {
id String @id @default(cuid())
workOrderId String
componentItemId String
warehouseId String
locationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
componentItem InventoryItem @relation(fields: [componentItemId], references: [id], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([workOrderId, createdAt])
@@index([componentItemId, createdAt])
}
model WorkOrderCompletion {
id String @id @default(cuid())
workOrderId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([workOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique

View File

@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { manufacturingRouter } from "./modules/manufacturing/router.js";
import { projectsRouter } from "./modules/projects/router.js";
import { purchasingRouter } from "./modules/purchasing/router.js";
import { salesRouter } from "./modules/sales/router.js";
@@ -56,6 +57,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/manufacturing", manufacturingRouter);
app.use("/api/v1/projects", projectsRouter);
app.use("/api/v1/purchasing", purchasingRouter);
app.use("/api/v1/sales", salesRouter);

View File

@@ -13,6 +13,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.crmWrite]: "Manage CRM records",
[permissions.inventoryRead]: "View inventory items and BOMs",
[permissions.inventoryWrite]: "Manage inventory items and BOMs",
[permissions.manufacturingRead]: "View manufacturing work orders and execution data",
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
[permissions.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View gantt timelines",

View File

@@ -0,0 +1,180 @@
import { permissions } from "@mrp/shared";
import { workOrderStatuses } from "@mrp/shared/dist/manufacturing/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createWorkOrder,
getWorkOrderById,
issueWorkOrderMaterial,
listManufacturingItemOptions,
listManufacturingProjectOptions,
listWorkOrders,
recordWorkOrderCompletion,
updateWorkOrder,
updateWorkOrderStatus,
} from "./service.js";
const workOrderSchema = z.object({
itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
dueDate: z.string().datetime().nullable(),
notes: z.string(),
});
const workOrderFiltersSchema = z.object({
q: z.string().optional(),
status: z.enum(workOrderStatuses).optional(),
projectId: z.string().optional(),
itemId: z.string().optional(),
});
const statusUpdateSchema = z.object({
status: z.enum(workOrderStatuses),
});
const materialIssueSchema = z.object({
componentItemId: z.string().trim().min(1),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
quantity: z.number().int().positive(),
notes: z.string(),
});
const completionSchema = z.object({
quantity: z.number().int().positive(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const manufacturingRouter = Router();
manufacturingRouter.get("/items/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingItemOptions());
});
manufacturingRouter.get("/projects/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/work-orders", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const parsed = workOrderFiltersSchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order filters are invalid.");
}
return ok(response, await listWorkOrders(parsed.data));
});
manufacturingRouter.get("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingRead]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return fail(response, 404, "WORK_ORDER_NOT_FOUND", "Work order was not found.");
}
return ok(response, workOrder);
});
manufacturingRouter.post("/work-orders", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await createWorkOrder(parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.put("/work-orders/:workOrderId", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = workOrderSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order payload is invalid.");
}
const result = await updateWorkOrder(workOrderId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/status", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = statusUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Work-order status payload is invalid.");
}
const result = await updateWorkOrderStatus(workOrderId, parsed.data.status);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = materialIssueSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Material-issue payload is invalid.");
}
const result = await issueWorkOrderMaterial(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});
manufacturingRouter.post("/work-orders/:workOrderId/completions", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {
return fail(response, 400, "INVALID_INPUT", "Work-order id is invalid.");
}
const parsed = completionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Completion payload is invalid.");
}
const result = await recordWorkOrderCompletion(workOrderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder, 201);
});

View File

@@ -0,0 +1,671 @@
import type {
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
} from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const workOrderModel = (prisma as any).workOrder;
type WorkOrderRecord = {
id: string;
workOrderNumber: string;
status: string;
quantity: number;
completedQuantity: number;
dueDate: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
item: {
id: string;
sku: string;
name: string;
type: string;
unitOfMeasure: string;
bomLines: Array<{
quantity: number;
unitOfMeasure: string;
componentItem: {
id: string;
sku: string;
name: string;
};
}>;
};
project: {
id: string;
projectNumber: string;
name: string;
customer: {
name: string;
};
} | null;
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
materialIssues: Array<{
id: string;
quantity: number;
notes: string;
createdAt: Date;
componentItem: {
id: string;
sku: string;
name: string;
};
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
completions: Array<{
id: string;
quantity: number;
notes: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
};
function buildInclude() {
return {
item: {
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
},
project: {
include: {
customer: {
select: {
name: true,
},
},
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
materialIssues: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
completions: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
};
}
function getUserName(user: { firstName: string; lastName: string } | null) {
return user ? `${user.firstName} ${user.lastName}`.trim() : "System";
}
function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
return {
id: record.id,
workOrderNumber: record.workOrderNumber,
status: record.status as WorkOrderStatus,
itemId: record.item.id,
itemSku: record.item.sku,
itemName: record.item.name,
projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null,
quantity: record.quantity,
completedQuantity: record.completedQuantity,
dueDate: record.dueDate ? record.dueDate.toISOString() : null,
warehouseId: record.warehouse.id,
warehouseCode: record.warehouse.code,
warehouseName: record.warehouse.name,
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
updatedAt: record.updatedAt.toISOString(),
};
}
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
const issuedByComponent = new Map<string, number>();
for (const issue of record.materialIssues) {
issuedByComponent.set(issue.componentItem.id, (issuedByComponent.get(issue.componentItem.id) ?? 0) + issue.quantity);
}
return {
...mapSummary(record),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
itemType: record.item.type,
itemUnitOfMeasure: record.item.unitOfMeasure,
projectCustomerName: record.project?.customer.name ?? null,
dueQuantity: record.quantity - record.completedQuantity,
materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
return {
componentItemId: line.componentItem.id,
componentSku: line.componentItem.sku,
componentName: line.componentItem.name,
unitOfMeasure: line.unitOfMeasure,
quantityPer: line.quantity,
requiredQuantity,
issuedQuantity,
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
};
}),
materialIssues: record.materialIssues.map((issue) => ({
id: issue.id,
componentItemId: issue.componentItem.id,
componentSku: issue.componentItem.sku,
componentName: issue.componentItem.name,
quantity: issue.quantity,
warehouseId: issue.warehouse.id,
warehouseCode: issue.warehouse.code,
warehouseName: issue.warehouse.name,
locationId: issue.location.id,
locationCode: issue.location.code,
locationName: issue.location.name,
notes: issue.notes,
createdAt: issue.createdAt.toISOString(),
createdByName: getUserName(issue.createdBy),
})),
completions: record.completions.map((completion) => ({
id: completion.id,
quantity: completion.quantity,
notes: completion.notes,
createdAt: completion.createdAt.toISOString(),
createdByName: getUserName(completion.createdBy),
})),
};
}
async function nextWorkOrderNumber() {
const next = (await workOrderModel.count()) + 1;
return `WO-${String(next).padStart(5, "0")}`;
}
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
itemId,
warehouseId,
locationId,
},
select: {
transactionType: true,
quantity: true,
},
});
return transactions.reduce((total, transaction) => {
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
}, 0);
}
async function validateWorkOrderInput(payload: WorkOrderInput) {
const item = await prisma.inventoryItem.findUnique({
where: { id: payload.itemId },
select: {
id: true,
type: true,
status: true,
},
});
if (!item) {
return { ok: false as const, reason: "Build item was not found." };
}
if (item.status !== "ACTIVE") {
return { ok: false as const, reason: "Build item must be active." };
}
if (item.type !== "ASSEMBLY" && item.type !== "MANUFACTURED") {
return { ok: false as const, reason: "Work orders can only be created for assembly or manufactured items." };
}
if (payload.projectId) {
const project = await prisma.project.findUnique({
where: { id: payload.projectId },
select: { id: true },
});
if (!project) {
return { ok: false as const, reason: "Linked project was not found." };
}
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
return { ok: true as const };
}
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
const items = await prisma.inventoryItem.findMany({
where: {
status: "ACTIVE",
type: {
in: ["ASSEMBLY", "MANUFACTURED"],
},
},
select: {
id: true,
sku: true,
name: true,
type: true,
unitOfMeasure: true,
},
orderBy: [{ sku: "asc" }],
});
return items;
}
export async function listManufacturingProjectOptions(): Promise<ManufacturingProjectOptionDto[]> {
const projects = await prisma.project.findMany({
where: {
status: {
notIn: ["COMPLETE"],
},
},
include: {
customer: {
select: {
name: true,
},
},
},
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return projects.map((project) => ({
id: project.id,
projectNumber: project.projectNumber,
name: project.name,
customerName: project.customer.name,
status: project.status,
}));
}
export async function listWorkOrders(filters: {
q?: string;
status?: WorkOrderStatus;
projectId?: string;
itemId?: string;
} = {}) {
const query = filters.q?.trim();
const workOrders = await workOrderModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(filters.projectId ? { projectId: filters.projectId } : {}),
...(filters.itemId ? { itemId: filters.itemId } : {}),
...(query
? {
OR: [
{ workOrderNumber: { contains: query } },
{ item: { sku: { contains: query } } },
{ item: { name: { contains: query } } },
{ project: { projectNumber: { contains: query } } },
{ project: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ dueDate: "asc" }, { updatedAt: "desc" }],
});
return workOrders.map((workOrder: unknown) => mapSummary(workOrder as WorkOrderRecord));
}
export async function getWorkOrderById(workOrderId: string) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null;
}
export async function createWorkOrder(payload: WorkOrderInput) {
const validated = await validateWorkOrderInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
const workOrderNumber = await nextWorkOrderNumber();
const created = await workOrderModel.create({
data: {
workOrderNumber,
itemId: payload.itemId,
projectId: payload.projectId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
quantity: payload.quantity,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
select: {
id: true,
},
});
const workOrder = await getWorkOrderById(created.id);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInput) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
id: true,
completedQuantity: true,
},
});
if (!existing) {
return { ok: false as const, reason: "Work order was not found." };
}
if (payload.quantity < existing.completedQuantity) {
return { ok: false as const, reason: "Planned quantity cannot be less than the already completed quantity." };
}
const validated = await validateWorkOrderInput(payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
await workOrderModel.update({
where: { id: workOrderId },
data: {
itemId: payload.itemId,
projectId: payload.projectId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
status: payload.status,
quantity: payload.quantity,
dueDate: payload.dueDate ? new Date(payload.dueDate) : null,
notes: payload.notes,
},
});
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrderStatus) {
const existing = await workOrderModel.findUnique({
where: { id: workOrderId },
select: {
id: true,
status: true,
quantity: true,
completedQuantity: true,
},
});
if (!existing) {
return { ok: false as const, reason: "Work order was not found." };
}
if (existing.status === "COMPLETE" && status !== "COMPLETE") {
return { ok: false as const, reason: "Completed work orders cannot be reopened from quick actions." };
}
if (status === "COMPLETE" && existing.completedQuantity < existing.quantity) {
return { ok: false as const, reason: "Use the completion action to finish a work order." };
}
await workOrderModel.update({
where: { id: workOrderId },
data: {
status,
},
});
const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
return { ok: false as const, reason: "Material can only be issued to released or active work orders." };
}
const componentRequirement = (workOrder as WorkOrderRecord).item.bomLines.find((line) => line.componentItem.id === payload.componentItemId);
if (!componentRequirement) {
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const currentDetail = mapDetail(workOrder as WorkOrderRecord);
const currentRequirement = currentDetail.materialRequirements.find(
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
);
if (!currentRequirement) {
return { ok: false as const, reason: "Issued material must be part of the work order BOM." };
}
if (payload.quantity > currentRequirement.remainingQuantity) {
return { ok: false as const, reason: "Material issue exceeds the remaining required quantity." };
}
const onHand = await getItemLocationOnHand(payload.componentItemId, payload.warehouseId, payload.locationId);
if (onHand < payload.quantity) {
return { ok: false as const, reason: "Material issue would drive the selected location below zero on-hand." };
}
await prisma.$transaction(async (tx) => {
const transactionClient = tx as any;
await transactionClient.workOrderMaterialIssue.create({
data: {
workOrderId,
componentItemId: payload.componentItemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.inventoryTransaction.create({
data: {
itemId: payload.componentItemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: `${(workOrder as WorkOrderRecord).workOrderNumber} material issue`,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.workOrder.update({
where: { id: workOrderId },
data: {
status: workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD" ? "IN_PROGRESS" : workOrder.status,
},
});
});
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}
export async function recordWorkOrderCompletion(workOrderId: string, payload: WorkOrderCompletionInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },
include: buildInclude(),
});
if (!workOrder) {
return { ok: false as const, reason: "Work order was not found." };
}
if (workOrder.status === "DRAFT" || workOrder.status === "CANCELLED" || workOrder.status === "COMPLETE") {
return { ok: false as const, reason: "Completion can only be posted to released or active work orders." };
}
const remainingQuantity = workOrder.quantity - workOrder.completedQuantity;
if (payload.quantity > remainingQuantity) {
return { ok: false as const, reason: "Completion quantity exceeds the remaining build quantity." };
}
const nextCompletedQuantity = workOrder.completedQuantity + payload.quantity;
const nextStatus = nextCompletedQuantity >= workOrder.quantity ? "COMPLETE" : "IN_PROGRESS";
await prisma.$transaction(async (tx) => {
const transactionClient = tx as any;
await transactionClient.workOrderCompletion.create({
data: {
workOrderId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.inventoryTransaction.create({
data: {
itemId: workOrder.item.id,
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
transactionType: "RECEIPT",
quantity: payload.quantity,
reference: `${workOrder.workOrderNumber} production completion`,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await transactionClient.workOrder.update({
where: { id: workOrderId },
data: {
completedQuantity: nextCompletedQuantity,
status: nextStatus,
},
});
});
const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
}

View File

@@ -6,6 +6,8 @@ export const permissions = {
crmWrite: "crm.write",
inventoryRead: "inventory.read",
inventoryWrite: "inventory.write",
manufacturingRead: "manufacturing.read",
manufacturingWrite: "manufacturing.write",
filesRead: "files.read",
filesWrite: "files.write",
ganttRead: "gantt.read",

View File

@@ -6,6 +6,7 @@ export * from "./crm/types.js";
export * from "./files/types.js";
export * from "./gantt/types.js";
export * from "./inventory/types.js";
export * from "./manufacturing/types.js";
export * from "./projects/types.js";
export * from "./purchasing/types.js";
export * from "./sales/types.js";

View File

@@ -0,0 +1,113 @@
export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const;
export type WorkOrderStatus = (typeof workOrderStatuses)[number];
export interface ManufacturingProjectOptionDto {
id: string;
projectNumber: string;
name: string;
customerName: string;
status: string;
}
export interface ManufacturingItemOptionDto {
id: string;
sku: string;
name: string;
type: string;
unitOfMeasure: string;
}
export interface WorkOrderSummaryDto {
id: string;
workOrderNumber: string;
status: WorkOrderStatus;
itemId: string;
itemSku: string;
itemName: string;
projectId: string | null;
projectNumber: string | null;
projectName: string | null;
quantity: number;
completedQuantity: number;
dueDate: string | null;
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
updatedAt: string;
}
export interface WorkOrderMaterialRequirementDto {
componentItemId: string;
componentSku: string;
componentName: string;
unitOfMeasure: string;
quantityPer: number;
requiredQuantity: number;
issuedQuantity: number;
remainingQuantity: number;
}
export interface WorkOrderMaterialIssueDto {
id: string;
componentItemId: string;
componentSku: string;
componentName: string;
quantity: number;
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
notes: string;
createdAt: string;
createdByName: string;
}
export interface WorkOrderCompletionDto {
id: string;
quantity: number;
notes: string;
createdAt: string;
createdByName: string;
}
export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
notes: string;
createdAt: string;
itemType: string;
itemUnitOfMeasure: string;
projectCustomerName: string | null;
dueQuantity: number;
materialRequirements: WorkOrderMaterialRequirementDto[];
materialIssues: WorkOrderMaterialIssueDto[];
completions: WorkOrderCompletionDto[];
}
export interface WorkOrderInput {
itemId: string;
projectId: string | null;
status: WorkOrderStatus;
quantity: number;
warehouseId: string;
locationId: string;
dueDate: string | null;
notes: string;
}
export interface WorkOrderMaterialIssueInput {
componentItemId: string;
warehouseId: string;
locationId: string;
quantity: number;
notes: string;
}
export interface WorkOrderCompletionInput {
quantity: number;
notes: string;
}