Compare commits

..

2 Commits

Author SHA1 Message Date
59754c7657 PO logic 2026-03-15 16:40:25 -05:00
15116807ce demand planning 2026-03-15 15:45:29 -05:00
33 changed files with 2437 additions and 22 deletions

View File

@@ -23,6 +23,10 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow

View File

@@ -6,6 +6,14 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail
- Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning
- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children
- Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly on sales-order detail pages
- Pegged work-order and purchase-order supply tracking back to sales demand so reopened planning views do not overstate remaining recommendations
- Preferred-vendor sourcing on inventory items, used to preseed buy-side planning workflows
- Demand-planning recommendations now reduce against existing linked WO/PO supply and support safer partial conversion behavior
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics - Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
- Exportable support bundles that now include backup guidance and recent support logs for support handoff - Exportable support bundles that now include backup guidance and recent support logs for support handoff
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups - Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
@@ -52,8 +60,10 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form - Company settings now acts as the staging area for admin surfaces while user administration lives on its own dedicated page instead of inside the company-profile form
- Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity - Admin diagnostics now includes startup-readiness status alongside runtime footprint and recent audit activity
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging - Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
- Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice - Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
- Roadmap and project docs now treat user/session visibility and destructive-action safety as the next active priorities after the diagnostics/support-debugging slices - Manufacturing work-order material requirements now include live available/shortage visibility instead of only required-versus-issued math
- Roadmap and project docs now move back to admin session visibility and destructive-action safety after the demand-planning rollout
## 2026-03-15 ## 2026-03-15

View File

@@ -27,6 +27,10 @@ This repository implements the platform foundation milestone:
- projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility - projects with customer/commercial/shipment linkage, owners, due dates, notes, attachments, and dashboard visibility
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through diagnostics - CRM/shipping audit coverage and startup validation surfaced through diagnostics

View File

@@ -26,6 +26,10 @@ Current foundation scope includes:
- projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments - projects with customer/commercial/shipment linkage, owners, due dates, notes, and attachments
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
@@ -351,6 +355,9 @@ The current admin operations slice supports:
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity - an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations
- prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations
- shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot

View File

@@ -60,6 +60,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Theme persistence fixes and denser responsive workspace layouts - Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens - Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- Live planning gantt timelines driven by project and manufacturing data - Live planning gantt timelines driven by project and manufacturing data
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- Multi-stage Docker packaging and migration-aware entrypoint - Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow - Docker image validated locally with successful app startup and login flow
- Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md` - Core project documentation in `README.md`, `INSTRUCTIONS.md`, and `STRUCTURE.md`
@@ -253,7 +254,34 @@ QOL subfeatures:
- Better mobile and tablet behavior for shop-floor lookups - Better mobile and tablet behavior for shop-floor lookups
- Faster filtering by project, customer, work center, and status - Faster filtering by project, customer, work center, and status
### Phase 8: Security, audit, and operations maturity ### Phase 8: Demand planning and supply generation
Foundation slice shipped:
- Sales-order demand planning from approved or active demand records
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children
- Netting against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly from the sales-order workflow
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
- Pegged work-order and purchase-order supply links back to originating sales demand
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
- Shared MRP demand engine across sales, inventory, purchasing, manufacturing, projects, and planning
- Planned work-order and purchase-order recommendation generation
- Coverage, shortage, and lateness rollups from customer demand down through supply layers
- Cross-module shortage visibility on sales orders, projects, work orders, purchasing, and dashboard widgets
QOL subfeatures:
- One-click conversion of planning recommendations into work orders and purchase orders
- Better shortage and substitute-part guidance during planning review
- Saved planning views by customer, project, item family, and shortage state
- Planner-focused drilldowns from demand source to buy/build action without re-keying data
- Time-phased supply recommendations with vendor lead times and build timing
### Phase 9: Security, audit, and operations maturity
Foundation slice shipped: Foundation slice shipped:

View File

@@ -68,8 +68,10 @@ import type {
} from "@mrp/shared/dist/projects/types.js"; } from "@mrp/shared/dist/projects/types.js";
import type { import type {
SalesCustomerOptionDto, SalesCustomerOptionDto,
DemandPlanningRollupDto,
SalesDocumentDetailDto, SalesDocumentDetailDto,
SalesDocumentInput, SalesDocumentInput,
SalesOrderPlanningDto,
SalesDocumentRevisionDto, SalesDocumentRevisionDto,
SalesDocumentStatus, SalesDocumentStatus,
SalesDocumentSummaryDto, SalesDocumentSummaryDto,
@@ -631,6 +633,12 @@ export const api = {
getSalesOrder(token: string, orderId: string) { getSalesOrder(token: string, orderId: string) {
return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token); return request<SalesDocumentDetailDto>(`/api/v1/sales/orders/${orderId}`, undefined, token);
}, },
getSalesOrderPlanning(token: string, orderId: string) {
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
},
getDemandPlanningRollup(token: string) {
return request<DemandPlanningRollupDto>("/api/v1/sales/planning-rollup", undefined, token);
},
createSalesOrder(token: string, payload: SalesDocumentInput) { createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token); return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
}, },

View File

@@ -1,4 +1,5 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -16,6 +17,7 @@ interface DashboardSnapshot {
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null; orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | null; shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
projects: Awaited<ReturnType<typeof api.getProjects>> | null; projects: Awaited<ReturnType<typeof api.getProjects>> | null;
planningRollup: DemandPlanningRollupDto | null;
refreshedAt: string; refreshedAt: string;
} }
@@ -87,8 +89,9 @@ export function DashboardPage() {
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null), canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null), canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null), canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null), canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null), canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
]); ]);
if (!isMounted) { if (!isMounted) {
@@ -112,6 +115,7 @@ export function DashboardPage() {
orders: results[7].status === "fulfilled" ? results[7].value : null, orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null, shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null, projects: results[9].status === "fulfilled" ? results[9].value : null,
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
}); });
setIsLoading(false); setIsLoading(false);
@@ -142,6 +146,7 @@ export function DashboardPage() {
const orders = snapshot?.orders ?? []; const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? []; const shipments = snapshot?.shipments ?? [];
const projects = snapshot?.projects ?? []; const projects = snapshot?.projects ?? [];
const planningRollup = snapshot?.planningRollup;
const accessibleModules = [ const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null, snapshot?.customers !== null || snapshot?.vendors !== null,
@@ -199,6 +204,10 @@ export function DashboardPage() {
return new Date(project.dueDate).getTime() < Date.now(); return new Date(project.dueDate).getTime() < Date.now();
}).length; }).length;
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
const lastActivityAt = [ const lastActivityAt = [
...customers.map((customer) => customer.updatedAt), ...customers.map((customer) => customer.updatedAt),
@@ -279,6 +288,14 @@ export function DashboardPage() {
: "Project metrics are permission-gated.", : "Project metrics are permission-gated.",
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300", tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
}, },
{
label: "Material Readiness",
value: planningRollup ? `${shortageItemCount}` : "No access",
detail: planningRollup
? `${buildRecommendationCount} build and ${buyRecommendationCount} buy recommendations`
: "Sales read permission is required to surface shortage rollups.",
tone: "border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
},
]; ];
const modulePanels = [ const modulePanels = [
@@ -404,12 +421,12 @@ export function DashboardPage() {
title: "Planning", title: "Planning",
eyebrow: "Schedule Visibility", eyebrow: "Schedule Visibility",
summary: canReadPlanning summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view." ? "Live gantt planning now pulls directly from active projects and open manufacturing work orders, with shared shortage/readiness rollups alongside schedule pressure."
: "Planning read permission is required to surface the live gantt schedule.", : "Planning read permission is required to surface the live gantt schedule.",
metrics: [ metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" }, { label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" }, { label: "Shortage items", value: canReadPlanning && planningRollup ? `${shortageItemCount}` : "No access" },
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" }, { label: "Build / buy", value: canReadPlanning && planningRollup ? `${buildRecommendationCount} / ${buyRecommendationCount}` : "No access" },
], ],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [], links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
}, },
@@ -533,6 +550,28 @@ export function DashboardPage() {
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-6"> <section className="grid gap-3 xl:grid-cols-6">
<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">Planning Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Shared shortage and readiness</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">Shortage items</span>
<span className="font-semibold text-text">{planningRollup ? `${shortageItemCount}` : "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">Build recommendations</span>
<span className="font-semibold text-text">{planningRollup ? `${buildRecommendationCount}` : "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">Buy recommendations</span>
<span className="font-semibold text-text">{planningRollup ? `${buyRecommendationCount}` : "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">Uncovered qty</span>
<span className="font-semibold text-text">{planningRollup ? `${totalUncoveredQuantity}` : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p> <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> <h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>

View File

@@ -3,7 +3,7 @@ import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css"; import "@svar-ui/react-gantt/style.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared"; import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api"; import { ApiError, api } from "../../lib/api";
@@ -24,6 +24,7 @@ export function GanttPage() {
const { token } = useAuth(); const { token } = useAuth();
const { mode } = useTheme(); const { mode } = useTheme();
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null); const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline..."); const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => { useEffect(() => {
@@ -31,10 +32,10 @@ export function GanttPage() {
return; return;
} }
api Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.getPlanningTimeline(token) .then(([data, rollup]) => {
.then((data) => {
setTimeline(data); setTimeline(data);
setPlanningRollup(rollup);
setStatus("Planning timeline loaded."); setStatus("Planning timeline loaded.");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -90,6 +91,16 @@ export function GanttPage() {
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div> <div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build / Buy</p>
<div className="mt-2 text-xl font-extrabold text-text">
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
</div>
</article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div <div
@@ -148,6 +159,16 @@ export function GanttPage() {
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
<div className="mt-4 space-y-2 rounded-3xl border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Uncovered quantity</span>
<span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Projects with linked demand</span>
<span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open projects Open projects

View File

@@ -252,6 +252,10 @@ export function InventoryDetailPage() {
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd> <dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
</div> </div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
</div>
<div> <div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<dd className="mt-2 text-sm text-text"> <dd className="mt-2 text-sm text-text">

View File

@@ -1,3 +1,4 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared"; import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -18,8 +19,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput); const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]); const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]); const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]); const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null); const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item..."); const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -72,6 +76,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
unitOfMeasure: item.unitOfMeasure, unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable, isSellable: item.isSellable,
isPurchasable: item.isPurchasable, isPurchasable: item.isPurchasable,
preferredVendorId: item.preferredVendorId,
defaultCost: item.defaultCost, defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice, defaultPrice: item.defaultPrice,
notes: item.notes, notes: item.notes,
@@ -93,6 +98,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
}); });
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku)); setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded."); setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? "");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item."; const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
@@ -106,12 +112,21 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
} }
api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
}, [token]); }, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) { function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
} }
function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) {
return "";
}
return vendorOptions.find((vendor) => vendor.id === vendorId)?.name ?? "";
}
function updateBomLine(index: number, nextLine: InventoryBomLineInput) { function updateBomLine(index: number, nextLine: InventoryBomLineInput) {
setForm((current) => ({ setForm((current) => ({
...current, ...current,
@@ -315,6 +330,76 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</label> </label>
</div> </div>
</div> </div>
<label className="block xl:max-w-xl">
<span className="mb-2 block text-sm font-semibold text-text">Preferred vendor</span>
<div className="relative">
<input
value={vendorSearchTerm}
onChange={(event) => {
setVendorSearchTerm(event.target.value);
updateField("preferredVendorId", null);
setVendorPickerOpen(true);
}}
onFocus={() => setVendorPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setVendorPickerOpen(false);
if (form.preferredVendorId) {
setVendorSearchTerm(getSelectedVendorName(form.preferredVendorId));
}
}, 120);
}}
disabled={!form.isPurchasable}
placeholder={form.isPurchasable ? "Search vendor" : "Enable purchasable to assign sourcing"}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
/>
{vendorPickerOpen && form.isPurchasable ? (
<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("preferredVendorId", null);
setVendorSearchTerm("");
setVendorPickerOpen(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 preferred vendor</div>
</button>
{vendorOptions
.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((vendor) => (
<button
key={vendor.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("preferredVendorId", vendor.id);
setVendorSearchTerm(vendor.name);
setVendorPickerOpen(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">{vendor.name}</div>
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
</button>
))}
</div>
) : null}
</div>
<div className="mt-2 text-xs text-muted">
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Demand planning uses this vendor when creating buy recommendations."}
</div>
</label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span> <span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea <textarea

View File

@@ -41,6 +41,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
unitOfMeasure: "EA", unitOfMeasure: "EA",
isSellable: true, isSellable: true,
isPurchasable: true, isPurchasable: true,
preferredVendorId: null,
defaultCost: null, defaultCost: null,
defaultPrice: null, defaultPrice: null,
notes: "", notes: "",

View File

@@ -141,6 +141,7 @@ export function WorkOrderDetailPage() {
<div className="flex flex-wrap gap-3"> <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> <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} {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}
{workOrder.salesOrderId ? <Link to={`/sales/orders/${workOrder.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</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> <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} {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>
@@ -170,6 +171,7 @@ export function WorkOrderDetailPage() {
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article> <article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article> <article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
<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">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -179,6 +181,7 @@ export function WorkOrderDetailPage() {
<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">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">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> <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>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -299,6 +302,8 @@ export function WorkOrderDetailPage() {
<th className="px-3 py-3">Required</th> <th className="px-3 py-3">Required</th>
<th className="px-3 py-3">Issued</th> <th className="px-3 py-3">Issued</th>
<th className="px-3 py-3">Remaining</th> <th className="px-3 py-3">Remaining</th>
<th className="px-3 py-3">Available</th>
<th className="px-3 py-3">Shortage</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-line/70"> <tbody className="divide-y divide-line/70">
@@ -309,6 +314,8 @@ export function WorkOrderDetailPage() {
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</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.issuedQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td> <td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.availableQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.shortageQuantity}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -17,6 +17,13 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { workOrderId } = useParams(); const { workOrderId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId"); const seededProjectId = searchParams.get("projectId");
const seededItemId = searchParams.get("itemId");
const seededSalesOrderId = searchParams.get("salesOrderId");
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
const seededQuantity = searchParams.get("quantity");
const seededStatus = searchParams.get("status");
const seededDueDate = searchParams.get("dueDate");
const seededNotes = searchParams.get("notes");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput); const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]); const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]); const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -33,7 +40,25 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
return; return;
} }
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([])); api.getManufacturingItemOptions(token).then((options) => {
setItemOptions(options);
if (mode === "create" && seededItemId) {
const seededItem = options.find((option) => option.id === seededItemId);
if (seededItem) {
setForm((current) => ({
...current,
itemId: seededItem.id,
salesOrderId: seededSalesOrderId || current.salesOrderId,
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
dueDate: seededDueDate || current.dueDate,
notes: seededNotes || current.notes,
}));
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
}
}
}).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then((options) => { api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options); setProjectOptions(options);
if (mode === "create" && seededProjectId) { if (mode === "create" && seededProjectId) {
@@ -45,7 +70,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
} }
}).catch(() => setProjectOptions([])); }).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([])); api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [mode, seededProjectId, token]); }, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) { if (!token || mode !== "edit" || !workOrderId) {
@@ -57,6 +82,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setForm({ setForm({
itemId: workOrder.itemId, itemId: workOrder.itemId,
projectId: workOrder.projectId, projectId: workOrder.projectId,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
status: workOrder.status, status: workOrder.status,
quantity: workOrder.quantity, quantity: workOrder.quantity,
warehouseId: workOrder.warehouseId, warehouseId: workOrder.warehouseId,

View File

@@ -26,6 +26,8 @@ export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
export const emptyWorkOrderInput: WorkOrderInput = { export const emptyWorkOrderInput: WorkOrderInput = {
itemId: "", itemId: "",
projectId: null, projectId: null,
salesOrderId: null,
salesOrderLineId: null,
status: "DRAFT", status: "DRAFT",
quantity: 1, quantity: 1,
warehouseId: "", warehouseId: "",

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js"; import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared"; import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -15,6 +16,7 @@ export function ProjectDetailPage() {
const { projectId } = useParams(); const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null); const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]); const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [status, setStatus] = useState("Loading project..."); const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false; const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -28,6 +30,11 @@ export function ProjectDetailPage() {
.then((nextProject) => { .then((nextProject) => {
setProject(nextProject); setProject(nextProject);
setStatus("Project loaded."); setStatus("Project loaded.");
if (nextProject.salesOrderId) {
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null));
} else {
setPlanning(null);
}
return api.getWorkOrders(token, { projectId: nextProject.id }); return api.getWorkOrders(token, { projectId: nextProject.id });
}) })
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders)) .then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
@@ -97,6 +104,47 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
</section> </section>
{planning ? (
<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 Readiness</p>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div>
</article>
</div>
<div className="mt-5 space-y-3">
{planning.items
.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)
.slice(0, 8)
.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
</section>
) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>

View File

@@ -2,6 +2,7 @@ import { permissions } from "@mrp/shared";
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared"; import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js"; import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function PurchaseDetailPage() {
const [status, setStatus] = useState("Loading purchase order..."); const [status, setStatus] = useState("Loading purchase order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const canManage = user?.permissions.includes("purchasing.write") ?? false; const canManage = user?.permissions.includes("purchasing.write") ?? false;
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false); const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
@@ -41,6 +43,7 @@ export function PurchaseDetailPage() {
const message = error instanceof ApiError ? error.message : "Unable to load purchase order."; const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
setStatus(message); setStatus(message);
}); });
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
if (!canReceive) { if (!canReceive) {
return; return;
@@ -90,6 +93,8 @@ export function PurchaseDetailPage() {
const activeDocument = document; const activeDocument = document;
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0); const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
const demandContextItems =
planningRollup?.items.filter((item) => activeDocument.lines.some((line) => line.itemId === item.itemId) && (item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)) ?? [];
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) { function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value })); setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
@@ -258,6 +263,30 @@ export function PurchaseDetailPage() {
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Context</p>
{demandContextItems.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 active shared shortage or buy-signal records currently point at items on this purchase order.
</div>
) : (
<div className="mt-5 space-y-3">
{demandContextItems.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
)}
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
{activeDocument.lines.length === 0 ? ( {activeDocument.lines.length === 0 ? (
@@ -266,13 +295,16 @@ export function PurchaseDetailPage() {
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr> <tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
</thead> </thead>
<tbody className="divide-y divide-line/70 bg-surface"> <tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => ( {activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
<tr key={line.id}> <tr key={line.id}>
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td> <td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
<td className="px-2 py-2 text-muted">{line.description}</td> <td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">
{line.salesOrderId && line.salesOrderNumber ? <Link to={`/sales/orders/${line.salesOrderId}`} className="hover:text-brand">{line.salesOrderNumber}</Link> : "Unlinked"}
</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td> <td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td> <td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td> <td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>

View File

@@ -1,4 +1,4 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared"; import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -13,6 +13,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { orderId } = useParams(); const { orderId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId"); const seededVendorId = searchParams.get("vendorId");
const planningOrderId = searchParams.get("planningOrderId");
const selectedPlanningItemId = searchParams.get("itemId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput); const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order..."); const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]); const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -23,6 +25,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null); const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
for (const child of node.children) {
nodes.push(...collectRecommendedPurchaseNodes(child));
}
return nodes;
}
const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0); const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0);
const taxAmount = subtotal * (form.taxPercent / 100); const taxAmount = subtotal * (form.taxPercent / 100);
const total = subtotal + taxAmount + form.freightAmount; const total = subtotal + taxAmount + form.freightAmount;
@@ -45,6 +55,75 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([])); api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [mode, seededVendorId, token]); }, [mode, seededVendorId, token]);
useEffect(() => {
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
return;
}
api.getSalesOrderPlanning(token, planningOrderId)
.then((planning: SalesOrderPlanningDto) => {
const recommendedNodes = planning.lines.flatMap((line) =>
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
salesOrderLineId: line.lineId,
...node,
}))
);
const filteredNodes = selectedPlanningItemId
? recommendedNodes.filter((node) => node.itemId === selectedPlanningItemId)
: recommendedNodes;
const recommendedLines = filteredNodes.map((node, index) => {
const inventoryItem = itemOptions.find((option) => option.id === node.itemId);
return {
itemId: node.itemId,
description: node.itemName,
quantity: node.recommendedPurchaseQuantity,
unitOfMeasure: node.unitOfMeasure,
unitCost: inventoryItem?.defaultCost ?? 0,
salesOrderId: planning.orderId,
salesOrderLineId: node.salesOrderLineId,
position: (index + 1) * 10,
} satisfies PurchaseLineInput;
});
if (recommendedLines.length === 0) {
return;
}
const preferredVendorIds = [
...new Set(
recommendedLines
.map((line) => itemOptions.find((option) => option.id === line.itemId)?.preferredVendorId)
.filter((vendorId): vendorId is string => Boolean(vendorId))
),
];
const autoVendorId = seededVendorId || (preferredVendorIds.length === 1 ? preferredVendorIds[0] : null);
setForm((current) => ({
...current,
vendorId: current.vendorId || autoVendorId || "",
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
lines: current.lines.length > 0 ? current.lines : recommendedLines,
}));
if (autoVendorId) {
const autoVendor = vendors.find((vendor) => vendor.id === autoVendorId);
if (autoVendor) {
setVendorSearchTerm(autoVendor.name);
}
}
setLineSearchTerms((current) =>
current.length > 0 ? current : recommendedLines.map((line) => itemOptions.find((option) => option.id === line.itemId)?.sku ?? "")
);
setStatus(
preferredVendorIds.length > 1 && !seededVendorId
? `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}. Multiple preferred vendors exist, so confirm the vendor before saving.`
: `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}.`
);
})
.catch(() => {
setStatus("Unable to load demand-planning recommendations.");
});
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !orderId) { if (!token || mode !== "edit" || !orderId) {
return; return;
@@ -59,12 +138,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
taxPercent: document.taxPercent, taxPercent: document.taxPercent,
freightAmount: document.freightAmount, freightAmount: document.freightAmount,
notes: document.notes, notes: document.notes,
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number }) => ({ lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
itemId: line.itemId, itemId: line.itemId,
description: line.description, description: line.description,
quantity: line.quantity, quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure, unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost, unitCost: line.unitCost,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
position: line.position, position: line.position,
})), })),
}); });
@@ -308,6 +389,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
...line, ...line,
itemId: option.id, itemId: option.id,
description: line.description || option.name, description: line.description || option.name,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
}); });
updateLineSearchTerm(index, option.sku); updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null); setActiveLinePicker(null);

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { SalesDocumentDetailDto, SalesDocumentStatus } from "@mrp/shared/dist/sales/types.js"; import type { SalesDocumentDetailDto, SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js";
import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; import type { ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
@@ -10,6 +10,41 @@ import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./co
import { SalesStatusBadge } from "./SalesStatusBadge"; import { SalesStatusBadge } from "./SalesStatusBadge";
import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge"; import { ShipmentStatusBadge } from "../shipping/ShipmentStatusBadge";
function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
return (
<div className="rounded-3xl border border-line/70 bg-page/60 p-3" style={{ marginLeft: node.level * 12 }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">
{node.itemSku} <span className="text-muted">{node.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Demand {node.grossDemand} {node.unitOfMeasure} · Type {node.itemType}
{node.bomQuantityPerParent !== null ? ` · Qty/parent ${node.bomQuantityPerParent}` : ""}
</div>
</div>
<div className="text-right text-xs text-muted">
<div>Linked WO {node.linkedWorkOrderSupply}</div>
<div>Linked PO {node.linkedPurchaseSupply}</div>
<div>Stock {node.supplyFromStock}</div>
<div>Open WO {node.supplyFromOpenWorkOrders}</div>
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
<div>Build {node.recommendedBuildQuantity}</div>
<div>Buy {node.recommendedPurchaseQuantity}</div>
{node.uncoveredQuantity > 0 ? <div>Uncovered {node.uncoveredQuantity}</div> : null}
</div>
</div>
{node.children.length > 0 ? (
<div className="mt-3 space-y-3">
{node.children.map((child) => (
<PlanningNodeCard key={`${child.itemId}-${child.level}-${child.itemSku}-${child.grossDemand}`} node={child} />
))}
</div>
) : null}
</div>
);
}
export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) { export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -23,10 +58,13 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [isApproving, setIsApproving] = useState(false); const [isApproving, setIsApproving] = useState(false);
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]); const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false; const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false; const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false;
const canManageManufacturing = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
const canManagePurchasing = user?.permissions.includes(permissions.purchasingWrite) ?? false;
useEffect(() => { useEffect(() => {
if (!token || !documentId) { if (!token || !documentId) {
@@ -34,9 +72,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
} }
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId); const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
loader const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
.then((nextDocument) => { Promise.all([loader, planningLoader])
.then(([nextDocument, nextPlanning]) => {
setDocument(nextDocument); setDocument(nextDocument);
setPlanning(nextPlanning);
setStatus(`${config.singularLabel} loaded.`); setStatus(`${config.singularLabel} loaded.`);
if (entity === "order" && canReadShipping) { if (entity === "order" && canReadShipping) {
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([])); api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
@@ -54,6 +94,31 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const activeDocument = document; const activeDocument = document;
function buildWorkOrderRecommendationLink(itemId: string, quantity: number) {
const params = new URLSearchParams({
itemId,
salesOrderId: activeDocument.id,
quantity: quantity.toString(),
status: "DRAFT",
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
});
return `/manufacturing/work-orders/new?${params.toString()}`;
}
function buildPurchaseRecommendationLink(itemId?: string, vendorId?: string | null) {
const params = new URLSearchParams();
params.set("planningOrderId", activeDocument.id);
if (itemId) {
params.set("itemId", itemId);
}
if (vendorId) {
params.set("vendorId", vendorId);
}
return `/purchasing/orders/new?${params.toString()}`;
}
async function handleStatusChange(nextStatus: SalesDocumentStatus) { async function handleStatusChange(nextStatus: SalesDocumentStatus) {
if (!token) { if (!token) {
return; return;
@@ -352,6 +417,125 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div> </div>
)} )}
</section> </section>
{entity === "order" && planning ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Demand Planning</p>
<h3 className="mt-2 text-lg font-bold text-text">Net build and buy requirements</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">
Sales-order demand is netted against available stock, active reservations, open work orders, and open purchase orders before new build or buy quantities are recommended.
</p>
</div>
<div className="text-right text-xs text-muted">
<div>Generated {new Date(planning.generatedAt).toLocaleString()}</div>
<div>Status {planning.status}</div>
</div>
</div>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.buildRecommendationCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Purchase Recommendations</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.purchaseRecommendationCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.uncoveredItemCount} items</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/70 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Planned Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.itemCount}</div>
<div className="mt-1 text-xs text-muted">{planning.summary.lineCount} sales lines</div>
</article>
</div>
<div className="mt-5 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr>
<th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Gross</th>
<th className="px-2 py-2">Linked WO</th>
<th className="px-2 py-2">Linked PO</th>
<th className="px-2 py-2">Available</th>
<th className="px-2 py-2">Open WO</th>
<th className="px-2 py-2">Open PO</th>
<th className="px-2 py-2">Build</th>
<th className="px-2 py-2">Buy</th>
<th className="px-2 py-2">Uncovered</th>
<th className="px-2 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{planning.items.map((item) => (
<tr key={item.itemId}>
<td className="px-2 py-2">
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</td>
<td className="px-2 py-2 text-muted">{item.grossDemand}</td>
<td className="px-2 py-2 text-muted">{item.linkedWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.linkedPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
<td className="px-2 py-2">
<div className="flex flex-wrap gap-2">
{canManageManufacturing && item.recommendedBuildQuantity > 0 ? (
<Link
to={buildWorkOrderRecommendationLink(item.itemId, item.recommendedBuildQuantity)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft WO
</Link>
) : null}
{canManagePurchasing && item.recommendedPurchaseQuantity > 0 ? (
<Link
to={buildPurchaseRecommendationLink(item.itemId)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft PO
</Link>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{canManagePurchasing && planning.summary.purchaseRecommendationCount > 0 ? (
<div className="mt-4 flex justify-end">
<Link to={buildPurchaseRecommendationLink()} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Draft purchase order from recommendations
</Link>
</div>
) : null}
<div className="mt-5 space-y-3">
{planning.lines.map((line) => (
<div key={line.lineId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="mb-3">
<div className="font-semibold text-text">
{line.itemSku} <span className="text-muted">{line.itemName}</span>
</div>
<div className="mt-1 text-xs text-muted">
Sales-order line demand: {line.quantity} {line.unitOfMeasure}
</div>
</div>
<PlanningNodeCard node={line.rootNode} />
</div>
))}
</div>
</section>
) : null}
{entity === "order" && canReadShipping ? ( {entity === "order" && canReadShipping ? (
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">

View File

@@ -0,0 +1,13 @@
ALTER TABLE "InventoryItem" ADD COLUMN "preferredVendorId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderLineId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderLineId" TEXT;
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "WorkOrder_salesOrderId_dueDate_idx" ON "WorkOrder"("salesOrderId", "dueDate");
CREATE INDEX "WorkOrder_salesOrderLineId_dueDate_idx" ON "WorkOrder"("salesOrderLineId", "dueDate");
CREATE INDEX "PurchaseOrderLine_salesOrderId_position_idx" ON "PurchaseOrderLine"("salesOrderId", "position");
CREATE INDEX "PurchaseOrderLine_salesOrderLineId_position_idx" ON "PurchaseOrderLine"("salesOrderLineId", "position");

View File

@@ -122,6 +122,7 @@ model InventoryItem {
unitOfMeasure String unitOfMeasure String
isSellable Boolean @default(true) isSellable Boolean @default(true)
isPurchasable Boolean @default(true) isPurchasable Boolean @default(true)
preferredVendorId String?
defaultCost Float? defaultCost Float?
defaultPrice Float? defaultPrice Float?
notes String notes String
@@ -138,6 +139,9 @@ model InventoryItem {
operations InventoryItemOperation[] operations InventoryItemOperation[]
reservations InventoryReservation[] reservations InventoryReservation[]
transfers InventoryTransfer[] transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
} }
model Warehouse { model Warehouse {
@@ -325,6 +329,7 @@ model Vendor {
contactEntries CrmContactEntry[] contactEntries CrmContactEntry[]
contacts CrmContact[] contacts CrmContact[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
preferredSupplyItems InventoryItem[]
} }
model CrmContactEntry { model CrmContactEntry {
@@ -417,6 +422,8 @@ model SalesOrder {
shipments Shipment[] shipments Shipment[]
projects Project[] projects Project[]
revisions SalesOrderRevision[] revisions SalesOrderRevision[]
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
} }
model SalesOrderLine { model SalesOrderLine {
@@ -432,6 +439,8 @@ model SalesOrderLine {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade) order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
@@index([orderId, position]) @@index([orderId, position])
} }
@@ -519,6 +528,8 @@ model WorkOrder {
workOrderNumber String @unique workOrderNumber String @unique
itemId String itemId String
projectId String? projectId String?
salesOrderId String?
salesOrderLineId String?
warehouseId String warehouseId String
locationId String locationId String
status String status String
@@ -530,6 +541,8 @@ model WorkOrder {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict) location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
operations WorkOrderOperation[] operations WorkOrderOperation[]
@@ -539,6 +552,8 @@ model WorkOrder {
@@index([itemId, createdAt]) @@index([itemId, createdAt])
@@index([projectId, dueDate]) @@index([projectId, dueDate])
@@index([salesOrderId, dueDate])
@@index([salesOrderLineId, dueDate])
@@index([status, dueDate]) @@index([status, dueDate])
@@index([warehouseId, createdAt]) @@index([warehouseId, createdAt])
} }
@@ -650,6 +665,8 @@ model PurchaseOrderLine {
id String @id @default(cuid()) id String @id @default(cuid())
purchaseOrderId String purchaseOrderId String
itemId String itemId String
salesOrderId String?
salesOrderLineId String?
description String description String
quantity Int quantity Int
unitOfMeasure String unitOfMeasure String
@@ -659,9 +676,13 @@ model PurchaseOrderLine {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade) purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
receiptLines PurchaseReceiptLine[] receiptLines PurchaseReceiptLine[]
@@index([purchaseOrderId, position]) @@index([purchaseOrderId, position])
@@index([salesOrderId, position])
@@index([salesOrderLineId, position])
} }
model PurchaseReceipt { model PurchaseReceipt {

View File

@@ -47,6 +47,7 @@ const inventoryItemSchema = z.object({
unitOfMeasure: z.enum(inventoryUnitsOfMeasure), unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
isSellable: z.boolean(), isSellable: z.boolean(),
isPurchasable: z.boolean(), isPurchasable: z.boolean(),
preferredVendorId: z.string().trim().min(1).nullable(),
defaultCost: z.number().nonnegative().nullable(), defaultCost: z.number().nonnegative().nullable(),
defaultPrice: z.number().nonnegative().nullable(), defaultPrice: z.number().nonnegative().nullable(),
notes: z.string(), notes: z.string(),

View File

@@ -65,6 +65,10 @@ type InventoryDetailRecord = {
unitOfMeasure: string; unitOfMeasure: string;
isSellable: boolean; isSellable: boolean;
isPurchasable: boolean; isPurchasable: boolean;
preferredVendor: {
id: string;
name: string;
} | null;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
notes: string; notes: string;
@@ -392,6 +396,8 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
description: record.description, description: record.description,
defaultCost: record.defaultCost, defaultCost: record.defaultCost,
defaultPrice: record.defaultPrice, defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null,
notes: record.notes, notes: record.notes,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine), bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -595,6 +601,30 @@ async function validateOperations(type: InventoryItemType, operations: Inventory
return { ok: true as const, operations: normalized }; return { ok: true as const, operations: normalized };
} }
async function validatePreferredVendor(isPurchasable: boolean, preferredVendorId: string | null) {
if (!preferredVendorId) {
return { ok: true as const };
}
if (!isPurchasable) {
return { ok: false as const, reason: "Only purchasable items may define a preferred vendor." };
}
const vendor = await prisma.vendor.findUnique({
where: { id: preferredVendorId },
select: {
id: true,
status: true,
},
});
if (!vendor || vendor.status === "INACTIVE") {
return { ok: false as const, reason: "Preferred vendor was not found or is inactive." };
}
return { ok: true as const };
}
async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) { async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) {
const reservations = await prisma.inventoryReservation.findMany({ const reservations = await prisma.inventoryReservation.findMany({
where: { where: {
@@ -639,7 +669,14 @@ export async function listInventoryItemOptions() {
sku: true, sku: true,
name: true, name: true,
isPurchasable: true, isPurchasable: true,
defaultCost: true,
defaultPrice: true, defaultPrice: true,
preferredVendor: {
select: {
id: true,
name: true,
},
},
}, },
orderBy: [{ sku: "asc" }], orderBy: [{ sku: "asc" }],
}); });
@@ -649,7 +686,10 @@ export async function listInventoryItemOptions() {
sku: item.sku, sku: item.sku,
name: item.name, name: item.name,
isPurchasable: item.isPurchasable, isPurchasable: item.isPurchasable,
defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice, defaultPrice: item.defaultPrice,
preferredVendorId: item.preferredVendor?.id ?? null,
preferredVendorName: item.preferredVendor?.name ?? null,
})); }));
} }
@@ -681,6 +721,12 @@ export async function getInventoryItemById(itemId: string) {
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },
preferredVendor: {
select: {
id: true,
name: true,
},
},
inventoryTransactions: { inventoryTransactions: {
include: { include: {
warehouse: { warehouse: {
@@ -1027,6 +1073,10 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedOperations.ok) { if (!validatedOperations.ok) {
return null; return null;
} }
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.create({ const item = await prisma.inventoryItem.create({
data: { data: {
@@ -1038,6 +1088,7 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
unitOfMeasure: payload.unitOfMeasure, unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable, isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable, isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost, defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice, defaultPrice: payload.defaultPrice,
notes: payload.notes, notes: payload.notes,
@@ -1091,6 +1142,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedOperations.ok) { if (!validatedOperations.ok) {
return null; return null;
} }
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.update({ const item = await prisma.inventoryItem.update({
where: { id: itemId }, where: { id: itemId },
@@ -1103,6 +1158,7 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
unitOfMeasure: payload.unitOfMeasure, unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable, isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable, isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost, defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice, defaultPrice: payload.defaultPrice,
notes: payload.notes, notes: payload.notes,

View File

@@ -30,6 +30,8 @@ const stationSchema = z.object({
const workOrderSchema = z.object({ const workOrderSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(), projectId: z.string().trim().min(1).nullable(),
salesOrderId: z.string().trim().min(1).nullable(),
salesOrderLineId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses), status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(), quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1), warehouseId: z.string().trim().min(1),

View File

@@ -75,6 +75,13 @@ type WorkOrderRecord = {
name: string; name: string;
}; };
} | null; } | null;
salesOrder: {
id: string;
documentNumber: string;
} | null;
salesOrderLine: {
id: string;
} | null;
warehouse: { warehouse: {
id: string; id: string;
code: string; code: string;
@@ -191,6 +198,17 @@ function buildInclude() {
}, },
}, },
}, },
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
salesOrderLine: {
select: {
id: true,
},
},
warehouse: { warehouse: {
select: { select: {
id: true, id: true,
@@ -278,6 +296,9 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
projectId: record.project?.id ?? null, projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null, projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null, projectName: record.project?.name ?? null,
salesOrderId: record.salesOrder?.id ?? null,
salesOrderLineId: record.salesOrderLine?.id ?? null,
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
quantity: record.quantity, quantity: record.quantity,
completedQuantity: record.completedQuantity, completedQuantity: record.completedQuantity,
dueDate: record.dueDate ? record.dueDate.toISOString() : null, dueDate: record.dueDate ? record.dueDate.toISOString() : null,
@@ -293,7 +314,10 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
}; };
} }
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto { function mapDetail(
record: WorkOrderRecord,
componentAvailability: Map<string, { onHandQuantity: number; reservedQuantity: number }>
): WorkOrderDetailDto {
const issuedByComponent = new Map<string, number>(); const issuedByComponent = new Map<string, number>();
for (const issue of record.materialIssues) { for (const issue of record.materialIssues) {
@@ -325,6 +349,11 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
materialRequirements: record.item.bomLines.map((line) => { materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity; const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0; const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
const availability = componentAvailability.get(line.componentItem.id) ?? { onHandQuantity: 0, reservedQuantity: 0 };
const onHandQuantity = availability.onHandQuantity;
const reservedQuantity = availability.reservedQuantity;
const availableQuantity = onHandQuantity - reservedQuantity;
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
return { return {
componentItemId: line.componentItem.id, componentItemId: line.componentItem.id,
@@ -335,6 +364,10 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
requiredQuantity, requiredQuantity,
issuedQuantity, issuedQuantity,
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0), remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
onHandQuantity,
reservedQuantity,
availableQuantity,
shortageQuantity,
}; };
}), }),
materialIssues: record.materialIssues.map((issue) => ({ materialIssues: record.materialIssues.map((issue) => ({
@@ -531,6 +564,63 @@ async function getItemLocationOnHand(itemId: string, warehouseId: string, locati
}, 0); }, 0);
} }
async function getComponentAvailability(workOrder: WorkOrderRecord) {
const componentItemIds = [...new Set(workOrder.item.bomLines.map((line) => line.componentItem.id))];
if (componentItemIds.length === 0) {
return new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
}
const [transactions, reservations] = await Promise.all([
prisma.inventoryTransaction.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
},
select: {
itemId: true,
transactionType: true,
quantity: true,
},
}),
prisma.inventoryReservation.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
status: "ACTIVE",
},
select: {
itemId: true,
quantity: true,
},
}),
]);
const availability = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
for (const itemId of componentItemIds) {
availability.set(itemId, { onHandQuantity: 0, reservedQuantity: 0 });
}
for (const transaction of transactions) {
const current = availability.get(transaction.itemId);
if (!current) {
continue;
}
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
}
for (const reservation of reservations) {
const current = availability.get(reservation.itemId);
if (!current) {
continue;
}
current.reservedQuantity += reservation.quantity;
}
return availability;
}
async function validateWorkOrderInput(payload: WorkOrderInput) { async function validateWorkOrderInput(payload: WorkOrderInput) {
const item = await prisma.inventoryItem.findUnique({ const item = await prisma.inventoryItem.findUnique({
where: { id: payload.itemId }, where: { id: payload.itemId },
@@ -573,6 +663,40 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
} }
} }
if (payload.salesOrderId) {
const salesOrder = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!salesOrder) {
return { ok: false as const, reason: "Linked sales order was not found." };
}
}
if (payload.salesOrderLineId) {
if (!payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line requires a linked sales order." };
}
const salesOrderLine = await prisma.salesOrderLine.findUnique({
where: { id: payload.salesOrderLineId },
select: {
id: true,
orderId: true,
itemId: true,
},
});
if (!salesOrderLine || salesOrderLine.orderId !== payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line was not found on the selected sales order." };
}
if (salesOrderLine.itemId !== payload.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the selected build item." };
}
}
const location = await prisma.warehouseLocation.findUnique({ const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId }, where: { id: payload.locationId },
select: { select: {
@@ -727,7 +851,11 @@ export async function getWorkOrderById(workOrderId: string) {
include: buildInclude(), include: buildInclude(),
}); });
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null; if (!workOrder) {
return null;
}
return mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
} }
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) { export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
@@ -742,6 +870,8 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
workOrderNumber, workOrderNumber,
itemId: payload.itemId, itemId: payload.itemId,
projectId: payload.projectId, projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId, warehouseId: payload.warehouseId,
locationId: payload.locationId, locationId: payload.locationId,
status: payload.status, status: payload.status,
@@ -804,6 +934,8 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
data: { data: {
itemId: payload.itemId, itemId: payload.itemId,
projectId: payload.projectId, projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId, warehouseId: payload.warehouseId,
locationId: payload.locationId, locationId: payload.locationId,
status: payload.status, status: payload.status,
@@ -916,7 +1048,7 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." }; return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
} }
const currentDetail = mapDetail(workOrder as WorkOrderRecord); const currentDetail = mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
const currentRequirement = currentDetail.materialRequirements.find( const currentRequirement = currentDetail.materialRequirements.find(
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId (requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
); );

View File

@@ -17,6 +17,8 @@ import {
const purchaseLineSchema = z.object({ const purchaseLineSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
salesOrderId: z.string().trim().min(1).nullable().optional(),
salesOrderLineId: z.string().trim().min(1).nullable().optional(),
description: z.string(), description: z.string(),
quantity: z.number().int().positive(), quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure), unitOfMeasure: z.enum(inventoryUnitsOfMeasure),

View File

@@ -45,6 +45,8 @@ export interface PurchaseOrderPdfData {
type PurchaseLineRecord = { type PurchaseLineRecord = {
id: string; id: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
description: string; description: string;
quantity: number; quantity: number;
unitOfMeasure: string; unitOfMeasure: string;
@@ -58,6 +60,10 @@ type PurchaseLineRecord = {
sku: string; sku: string;
name: string; name: string;
}; };
salesOrder: {
id: string;
documentNumber: string;
} | null;
}; };
type PurchaseReceiptLineRecord = { type PurchaseReceiptLineRecord = {
@@ -175,6 +181,8 @@ function normalizeLines(lines: PurchaseLineInput[]) {
return lines return lines
.map((line, index) => ({ .map((line, index) => ({
itemId: line.itemId, itemId: line.itemId,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
description: line.description.trim(), description: line.description.trim(),
quantity: Number(line.quantity), quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure, unitOfMeasure: line.unitOfMeasure,
@@ -213,6 +221,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." }; return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." };
} }
const salesOrderIds = [...new Set(normalized.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
const salesOrderLineIds = [...new Set(normalized.flatMap((line) => (line.salesOrderLineId ? [line.salesOrderLineId] : [])))];
if (normalized.some((line) => line.salesOrderLineId && !line.salesOrderId)) {
return { ok: false as const, reason: "Linked sales-order lines require a linked sales order." };
}
if (salesOrderIds.length > 0) {
const salesOrders = await prisma.salesOrder.findMany({
where: { id: { in: salesOrderIds } },
select: { id: true },
});
if (salesOrders.length !== salesOrderIds.length) {
return { ok: false as const, reason: "One or more linked sales orders do not exist." };
}
}
if (salesOrderLineIds.length > 0) {
const salesOrderLines = await prisma.salesOrderLine.findMany({
where: { id: { in: salesOrderLineIds } },
select: {
id: true,
orderId: true,
itemId: true,
},
});
const salesOrderLinesById = new Map(
salesOrderLines.map((line) => [
line.id,
{
orderId: line.orderId,
itemId: line.itemId,
},
])
);
if (salesOrderLines.length !== salesOrderLineIds.length) {
return { ok: false as const, reason: "One or more linked sales-order lines do not exist." };
}
for (const line of normalized) {
if (!line.salesOrderLineId) {
continue;
}
const salesOrderLine = salesOrderLinesById.get(line.salesOrderLineId);
if (!salesOrderLine || salesOrderLine.orderId !== line.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line does not belong to the selected sales order." };
}
if (salesOrderLine.itemId !== line.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the purchase item." };
}
}
}
return { ok: true as const, lines: normalized }; return { ok: true as const, lines: normalized };
} }
@@ -240,6 +305,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
lineTotal: line.quantity * line.unitCost, lineTotal: line.quantity * line.unitCost,
receivedQuantity: receivedByLineId.get(line.id) ?? 0, receivedQuantity: receivedByLineId.get(line.id) ?? 0,
remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)), remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)),
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
salesOrderNumber: line.salesOrder?.documentNumber ?? null,
position: line.position, position: line.position,
})); }));
const totals = calculateTotals( const totals = calculateTotals(
@@ -305,6 +373,12 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
name: true, name: true,
}, },
}, },
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import {
updateSalesDocumentStatus, updateSalesDocumentStatus,
updateSalesDocument, updateSalesDocument,
} from "./service.js"; } from "./service.js";
import { getDemandPlanningRollup, getSalesOrderPlanningById } from "./planning.js";
const salesLineSchema = z.object({ const salesLineSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
@@ -216,6 +217,24 @@ salesRouter.get("/orders/:orderId", requirePermissions([permissions.salesRead]),
return ok(response, order); return ok(response, order);
}); });
salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.salesRead]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Sales order id is invalid.");
}
const planning = await getSalesOrderPlanningById(orderId);
if (!planning) {
return fail(response, 404, "SALES_ORDER_NOT_FOUND", "Sales order was not found.");
}
return ok(response, planning);
});
salesRouter.get("/planning-rollup", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await getDemandPlanningRollup());
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => { salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body); const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { buildSalesOrderPlanning, type SalesOrderPlanningSnapshot } from "../src/modules/sales/planning.js";
describe("sales order planning", () => {
it("nets stock and open supply before cascading build demand into child components", () => {
const snapshot: SalesOrderPlanningSnapshot = {
orderId: "order-1",
documentNumber: "SO-00001",
status: "APPROVED",
lines: [
{
id: "line-1",
itemId: "assembly-1",
itemSku: "ASSY-100",
itemName: "Assembly",
quantity: 5,
unitOfMeasure: "EA",
},
],
itemsById: {
"assembly-1": {
id: "assembly-1",
sku: "ASSY-100",
name: "Assembly",
type: "ASSEMBLY",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "mfg-1",
quantity: 2,
unitOfMeasure: "EA",
},
{
componentItemId: "buy-1",
quantity: 3,
unitOfMeasure: "EA",
},
],
},
"mfg-1": {
id: "mfg-1",
sku: "MFG-200",
name: "Manufactured Child",
type: "MANUFACTURED",
unitOfMeasure: "EA",
isPurchasable: false,
bomLines: [
{
componentItemId: "buy-2",
quantity: 4,
unitOfMeasure: "EA",
},
],
},
"buy-1": {
id: "buy-1",
sku: "BUY-300",
name: "Purchased Child",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
"buy-2": {
id: "buy-2",
sku: "BUY-400",
name: "Raw Material",
type: "PURCHASED",
unitOfMeasure: "EA",
isPurchasable: true,
bomLines: [],
},
},
supplyByItemId: {
"assembly-1": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"mfg-1": {
onHandQuantity: 2,
reservedQuantity: 0,
availableQuantity: 2,
openWorkOrderSupply: 1,
openPurchaseSupply: 0,
},
"buy-1": {
onHandQuantity: 4,
reservedQuantity: 1,
availableQuantity: 3,
openWorkOrderSupply: 0,
openPurchaseSupply: 5,
},
"buy-2": {
onHandQuantity: 1,
reservedQuantity: 0,
availableQuantity: 1,
openWorkOrderSupply: 0,
openPurchaseSupply: 2,
},
},
orderLinkedSupplyByItemId: {
"assembly-1": {
linkedWorkOrderSupply: 1,
linkedPurchaseSupply: 0,
},
"mfg-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-2": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 3,
},
},
lineLinkedSupplyByLineId: {
"line-1": {
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 2,
},
},
},
};
const planning = buildSalesOrderPlanning(snapshot);
expect(planning.summary.totalBuildQuantity).toBe(3);
expect(planning.summary.totalPurchaseQuantity).toBe(0);
const assembly = planning.items.find((item) => item.itemId === "assembly-1");
const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1");
const purchasedChild = planning.items.find((item) => item.itemId === "buy-1");
const rawMaterial = planning.items.find((item) => item.itemId === "buy-2");
expect(assembly?.linkedWorkOrderSupply).toBe(1);
expect(assembly?.recommendedBuildQuantity).toBe(2);
expect(assembly?.supplyFromStock).toBe(1);
expect(assembly?.supplyFromOpenWorkOrders).toBe(1);
expect(manufacturedChild?.grossDemand).toBe(4);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(1);
expect(manufacturedChild?.supplyFromStock).toBe(2);
expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1);
expect(purchasedChild?.grossDemand).toBe(6);
expect(purchasedChild?.linkedPurchaseSupply).toBe(2);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(0);
expect(purchasedChild?.supplyFromStock).toBe(3);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(1);
expect(rawMaterial?.grossDemand).toBe(4);
expect(rawMaterial?.linkedPurchaseSupply).toBe(3);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(0);
expect(rawMaterial?.supplyFromStock).toBe(1);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(0);
});
});

View File

@@ -56,7 +56,10 @@ export interface InventoryItemOptionDto {
sku: string; sku: string;
name: string; name: string;
isPurchasable: boolean; isPurchasable: boolean;
defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
preferredVendorName: string | null;
} }
export interface WarehouseLocationOptionDto { export interface WarehouseLocationOptionDto {
@@ -210,6 +213,8 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
description: string; description: string;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
preferredVendorName: string | null;
notes: string; notes: string;
createdAt: string; createdAt: string;
bomLines: InventoryBomLineDto[]; bomLines: InventoryBomLineDto[];
@@ -234,6 +239,7 @@ export interface InventoryItemInput {
isPurchasable: boolean; isPurchasable: boolean;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
notes: string; notes: string;
bomLines: InventoryBomLineInput[]; bomLines: InventoryBomLineInput[];
operations: InventoryItemOperationInput[]; operations: InventoryItemOperationInput[];

View File

@@ -58,6 +58,9 @@ export interface WorkOrderSummaryDto {
locationId: string; locationId: string;
locationCode: string; locationCode: string;
locationName: string; locationName: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
salesOrderNumber: string | null;
operationCount: number; operationCount: number;
totalPlannedMinutes: number; totalPlannedMinutes: number;
updatedAt: string; updatedAt: string;
@@ -87,6 +90,10 @@ export interface WorkOrderMaterialRequirementDto {
requiredQuantity: number; requiredQuantity: number;
issuedQuantity: number; issuedQuantity: number;
remainingQuantity: number; remainingQuantity: number;
onHandQuantity: number;
reservedQuantity: number;
availableQuantity: number;
shortageQuantity: number;
} }
export interface WorkOrderMaterialIssueDto { export interface WorkOrderMaterialIssueDto {
@@ -130,6 +137,8 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
export interface WorkOrderInput { export interface WorkOrderInput {
itemId: string; itemId: string;
projectId: string | null; projectId: string | null;
salesOrderId: string | null;
salesOrderLineId: string | null;
status: WorkOrderStatus; status: WorkOrderStatus;
quantity: number; quantity: number;
warehouseId: string; warehouseId: string;

View File

@@ -24,6 +24,9 @@ export interface PurchaseLineDto {
lineTotal: number; lineTotal: number;
receivedQuantity: number; receivedQuantity: number;
remainingQuantity: number; remainingQuantity: number;
salesOrderId: string | null;
salesOrderLineId: string | null;
salesOrderNumber: string | null;
position: number; position: number;
} }
@@ -33,6 +36,8 @@ export interface PurchaseLineInput {
quantity: number; quantity: number;
unitOfMeasure: InventoryUnitOfMeasure; unitOfMeasure: InventoryUnitOfMeasure;
unitCost: number; unitCost: number;
salesOrderId?: string | null;
salesOrderLineId?: string | null;
position: number; position: number;
} }

View File

@@ -64,6 +64,116 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
revisions: SalesDocumentRevisionDto[]; revisions: SalesDocumentRevisionDto[];
} }
export interface SalesOrderPlanningNodeDto {
itemId: string;
itemSku: string;
itemName: string;
itemType: string;
unitOfMeasure: InventoryUnitOfMeasure;
level: number;
grossDemand: number;
availableBefore: number;
availableAfter: number;
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
supplyFromStock: number;
openWorkOrderSupply: number;
openPurchaseSupply: number;
supplyFromOpenWorkOrders: number;
supplyFromOpenPurchaseOrders: number;
recommendedBuildQuantity: number;
recommendedPurchaseQuantity: number;
uncoveredQuantity: number;
bomQuantityPerParent: number | null;
children: SalesOrderPlanningNodeDto[];
}
export interface SalesOrderPlanningLineDto {
lineId: string;
itemId: string;
itemSku: string;
itemName: string;
quantity: number;
unitOfMeasure: InventoryUnitOfMeasure;
rootNode: SalesOrderPlanningNodeDto;
}
export interface SalesOrderPlanningItemDto {
itemId: string;
itemSku: string;
itemName: string;
itemType: string;
unitOfMeasure: InventoryUnitOfMeasure;
grossDemand: number;
onHandQuantity: number;
reservedQuantity: number;
availableQuantity: number;
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
openWorkOrderSupply: number;
openPurchaseSupply: number;
supplyFromStock: number;
supplyFromOpenWorkOrders: number;
supplyFromOpenPurchaseOrders: number;
recommendedBuildQuantity: number;
recommendedPurchaseQuantity: number;
uncoveredQuantity: number;
}
export interface SalesOrderPlanningSummaryDto {
lineCount: number;
itemCount: number;
buildRecommendationCount: number;
purchaseRecommendationCount: number;
uncoveredItemCount: number;
totalBuildQuantity: number;
totalPurchaseQuantity: number;
totalUncoveredQuantity: number;
}
export interface SalesOrderPlanningDto {
orderId: string;
documentNumber: string;
status: SalesDocumentStatus;
generatedAt: string;
summary: SalesOrderPlanningSummaryDto;
lines: SalesOrderPlanningLineDto[];
items: SalesOrderPlanningItemDto[];
}
export interface DemandPlanningProjectSummaryDto {
projectId: string;
projectNumber: string;
projectName: string;
salesOrderId: string;
salesOrderNumber: string;
totalBuildQuantity: number;
totalPurchaseQuantity: number;
totalUncoveredQuantity: number;
buildRecommendationCount: number;
purchaseRecommendationCount: number;
uncoveredItemCount: number;
}
export interface DemandPlanningRollupSummaryDto {
orderCount: number;
projectCount: number;
itemCount: number;
buildRecommendationCount: number;
purchaseRecommendationCount: number;
uncoveredItemCount: number;
totalBuildQuantity: number;
totalPurchaseQuantity: number;
totalUncoveredQuantity: number;
}
export interface DemandPlanningRollupDto {
generatedAt: string;
summary: DemandPlanningRollupSummaryDto;
items: SalesOrderPlanningItemDto[];
projects: DemandPlanningProjectSummaryDto[];
}
export interface SalesDocumentInput { export interface SalesDocumentInput {
customerId: string; customerId: string;
status: SalesDocumentStatus; status: SalesDocumentStatus;