demand planning
This commit is contained in:
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user