This commit is contained in:
2026-03-15 16:40:25 -05:00
parent 15116807ce
commit 59754c7657
33 changed files with 1620 additions and 49 deletions

View File

@@ -68,6 +68,7 @@ import type {
} from "@mrp/shared/dist/projects/types.js";
import type {
SalesCustomerOptionDto,
DemandPlanningRollupDto,
SalesDocumentDetailDto,
SalesDocumentInput,
SalesOrderPlanningDto,
@@ -635,6 +636,9 @@ export const api = {
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) {
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 type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
@@ -16,6 +17,7 @@ interface DashboardSnapshot {
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
projects: Awaited<ReturnType<typeof api.getProjects>> | null;
planningRollup: DemandPlanningRollupDto | null;
refreshedAt: string;
}
@@ -87,8 +89,9 @@ export function DashboardPage() {
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
]);
if (!isMounted) {
@@ -112,6 +115,7 @@ export function DashboardPage() {
orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null,
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
refreshedAt: new Date().toISOString(),
});
setIsLoading(false);
@@ -142,6 +146,7 @@ export function DashboardPage() {
const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? [];
const projects = snapshot?.projects ?? [];
const planningRollup = snapshot?.planningRollup;
const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null,
@@ -199,6 +204,10 @@ export function DashboardPage() {
return new Date(project.dueDate).getTime() < Date.now();
}).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 = [
...customers.map((customer) => customer.updatedAt),
@@ -279,6 +288,14 @@ export function DashboardPage() {
: "Project metrics are permission-gated.",
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 = [
@@ -404,12 +421,12 @@ export function DashboardPage() {
title: "Planning",
eyebrow: "Schedule Visibility",
summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view."
? "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.",
metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" },
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" },
{ label: "Shortage items", value: canReadPlanning && planningRollup ? `${shortageItemCount}` : "No access" },
{ label: "Build / buy", value: canReadPlanning && planningRollup ? `${buildRecommendationCount} / ${buyRecommendationCount}` : "No access" },
],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
},
@@ -533,6 +550,28 @@ export function DashboardPage() {
))}
</section>
<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">
<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>

View File

@@ -3,7 +3,7 @@ import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css";
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 { ApiError, api } from "../../lib/api";
@@ -24,6 +24,7 @@ export function GanttPage() {
const { token } = useAuth();
const { mode } = useTheme();
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => {
@@ -31,10 +32,10 @@ export function GanttPage() {
return;
}
api
.getPlanningTimeline(token)
.then((data) => {
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.then(([data, rollup]) => {
setTimeline(data);
setPlanningRollup(rollup);
setStatus("Planning timeline loaded.");
})
.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>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 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">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>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div
@@ -148,6 +159,16 @@ export function GanttPage() {
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
<div className="mt-4 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">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
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>
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
</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>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<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 { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
@@ -18,8 +19,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
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 [isSaving, setIsSaving] = useState(false);
@@ -72,6 +76,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable,
isPurchasable: item.isPurchasable,
preferredVendorId: item.preferredVendorId,
defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice,
notes: item.notes,
@@ -93,6 +98,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
});
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? "");
})
.catch((error: unknown) => {
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.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
}, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
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) {
setForm((current) => ({
...current,
@@ -315,6 +330,76 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</label>
</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">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea

View File

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

View File

@@ -141,6 +141,7 @@ export function WorkOrderDetailPage() {
<div className="flex flex-wrap gap-3">
<Link to="/manufacturing/work-orders" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to work orders</Link>
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
{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>
{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>
@@ -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">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">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>
<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">
@@ -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">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">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
</dl>
</article>
<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">Issued</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>
</thead>
<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.issuedQuantity}</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>
))}
</tbody>

View File

@@ -17,6 +17,13 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { workOrderId } = useParams();
const [searchParams] = useSearchParams();
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 [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -33,7 +40,25 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
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) => {
setProjectOptions(options);
if (mode === "create" && seededProjectId) {
@@ -45,7 +70,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
}
}).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [mode, seededProjectId, token]);
}, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) {
@@ -57,6 +82,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setForm({
itemId: workOrder.itemId,
projectId: workOrder.projectId,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
status: workOrder.status,
quantity: workOrder.quantity,
warehouseId: workOrder.warehouseId,

View File

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

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared";
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 { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
@@ -15,6 +16,7 @@ export function ProjectDetailPage() {
const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -28,6 +30,11 @@ export function ProjectDetailPage() {
.then((nextProject) => {
setProject(nextProject);
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 });
})
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
@@ -97,6 +104,47 @@ export function ProjectDetailPage() {
</div>
</div>
</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">
<div className="flex items-center justify-between gap-3">
<div>

View File

@@ -2,6 +2,7 @@ import { permissions } from "@mrp/shared";
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/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 { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function PurchaseDetailPage() {
const [status, setStatus] = useState("Loading purchase order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const canManage = user?.permissions.includes("purchasing.write") ?? 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.";
setStatus(message);
});
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
if (!canReceive) {
return;
@@ -90,6 +93,8 @@ export function PurchaseDetailPage() {
const activeDocument = document;
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]) {
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>
</article>
</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">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
{activeDocument.lines.length === 0 ? (
@@ -266,13 +295,16 @@ export function PurchaseDetailPage() {
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">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>
<tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
<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 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.receivedQuantity}</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 { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -13,6 +13,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { orderId } = useParams();
const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId");
const planningOrderId = searchParams.get("planningOrderId");
const selectedPlanningItemId = searchParams.get("itemId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -23,6 +25,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
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 taxAmount = subtotal * (form.taxPercent / 100);
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([]));
}, [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(() => {
if (!token || mode !== "edit" || !orderId) {
return;
@@ -59,12 +138,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
taxPercent: document.taxPercent,
freightAmount: document.freightAmount,
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,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
position: line.position,
})),
});
@@ -308,6 +389,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
...line,
itemId: option.id,
description: line.description || option.name,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
});
updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null);

View File

@@ -24,6 +24,8 @@ function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
</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>
@@ -61,6 +63,8 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? 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(() => {
if (!token || !documentId) {
@@ -90,6 +94,31 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
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) {
if (!token) {
return;
@@ -431,12 +460,15 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<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">
@@ -447,17 +479,46 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<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">