demand planning

This commit is contained in:
2026-03-15 15:45:29 -05:00
parent f858fe4785
commit 15116807ce
11 changed files with 859 additions and 15 deletions

View File

@@ -70,6 +70,7 @@ import type {
SalesCustomerOptionDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesOrderPlanningDto,
SalesDocumentRevisionDto,
SalesDocumentStatus,
SalesDocumentSummaryDto,
@@ -631,6 +632,9 @@ export const api = {
getSalesOrder(token: string, orderId: string) {
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);
},
createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
},

View File

@@ -1,5 +1,5 @@
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 { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -10,6 +10,39 @@ import { salesConfigs, salesStatusOptions, type SalesDocumentEntity } from "./co
import { SalesStatusBadge } from "./SalesStatusBadge";
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>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 }) {
const { token, user } = useAuth();
const navigate = useNavigate();
@@ -23,6 +56,7 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [shipments, setShipments] = useState<ShipmentSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
@@ -34,9 +68,11 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
}
const loader = entity === "quote" ? api.getQuote(token, documentId) : api.getSalesOrder(token, documentId);
loader
.then((nextDocument) => {
const planningLoader = entity === "order" ? api.getSalesOrderPlanning(token, documentId) : Promise.resolve(null);
Promise.all([loader, planningLoader])
.then(([nextDocument, nextPlanning]) => {
setDocument(nextDocument);
setPlanning(nextPlanning);
setStatus(`${config.singularLabel} loaded.`);
if (entity === "order" && canReadShipping) {
api.getShipments(token, { salesOrderId: nextDocument.id }).then(setShipments).catch(() => setShipments([]));
@@ -352,6 +388,93 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
</div>
)}
</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">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>
</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.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>
</tr>
))}
</tbody>
</table>
</div>
<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 ? (
<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">