sku builder first test

This commit is contained in:
2026-03-15 22:17:58 -05:00
parent f2b820746a
commit 2718e8b4b1
15 changed files with 1463 additions and 74 deletions
@@ -1,5 +1,13 @@
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,
InventorySkuBuilderPreviewDto,
InventorySkuFamilyDto,
InventorySkuNodeDto,
} from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -28,6 +36,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
const [skuFamilies, setSkuFamilies] = useState<InventorySkuFamilyDto[]>([]);
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null;
@@ -37,6 +49,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
return getComponentOption(componentItemId)?.sku ?? "";
}
function getSkuFamilyName(familyId: string | null) {
if (!familyId) {
return "";
}
return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
}
useEffect(() => {
if (!token) {
return;
@@ -71,6 +91,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
.then((item) => {
setForm({
sku: item.sku,
skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
name: item.name,
description: item.description,
type: item.type,
@@ -98,6 +119,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
notes: operation.notes,
})),
});
setSelectedSkuNodeIds(item.skuBuilder?.nodePath.map((entry) => entry.id) ?? []);
setSkuPreview(item.skuBuilder ? { ...item.skuBuilder, nextSequenceNumber: item.skuBuilder.sequenceNumber ?? 0, availableLevels: Math.max(0, 6 - item.skuBuilder.segments.length), hasChildren: false } : null);
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? "");
@@ -115,12 +138,104 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
}, [token]);
useEffect(() => {
const familyId = form.skuBuilder?.familyId ?? null;
if (!token || !familyId) {
setSkuLevelOptions([]);
setSelectedSkuNodeIds([]);
setSkuPreview(null);
return;
}
let cancelled = false;
const activeFamilyId: string = familyId;
const activeToken: string = token;
async function loadSkuBuilderState() {
const nextOptions: InventorySkuNodeDto[][] = [];
let parentNodeId: string | null = null;
const lineage = selectedSkuNodeIds.filter((value): value is string => Boolean(value));
for (let levelIndex = 0; levelIndex <= lineage.length; levelIndex += 1) {
const options = await api.getInventorySkuNodes(activeToken, activeFamilyId, parentNodeId ?? undefined);
if (!options.length) {
break;
}
nextOptions.push(options);
const nextSelectedNodeId = lineage[levelIndex];
if (!nextSelectedNodeId || !options.some((option) => option.id === nextSelectedNodeId)) {
break;
}
parentNodeId = nextSelectedNodeId;
}
const leafNodeId = lineage.length > 0 ? lineage[lineage.length - 1] : null;
const preview = await api.getInventorySkuPreview(activeToken, activeFamilyId, leafNodeId ?? undefined);
if (!cancelled) {
setSkuLevelOptions(nextOptions);
setSkuPreview(preview);
setForm((current) => ({
...current,
sku: preview.generatedSku,
skuBuilder: current.skuBuilder ? { familyId: current.skuBuilder.familyId, nodeId: leafNodeId ?? null } : current.skuBuilder,
}));
}
}
void loadSkuBuilderState().catch(() => {
if (!cancelled) {
setSkuLevelOptions([]);
setSkuPreview(null);
}
});
return () => {
cancelled = true;
};
}, [form.skuBuilder?.familyId, selectedSkuNodeIds, token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function updateSkuFamily(familyId: string) {
if (!familyId) {
setSelectedSkuNodeIds([]);
setSkuLevelOptions([]);
setSkuPreview(null);
setForm((current) => ({
...current,
skuBuilder: null,
sku: mode === "edit" && !current.skuBuilder ? current.sku : "",
}));
return;
}
setSelectedSkuNodeIds([]);
setForm((current) => ({
...current,
skuBuilder: {
familyId,
nodeId: null,
},
}));
}
function updateSkuNode(levelIndex: number, nodeId: string) {
setSelectedSkuNodeIds((current) => {
const next = current.slice(0, levelIndex);
if (nodeId) {
next[levelIndex] = nodeId;
}
return next;
});
}
function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) {
return "";
@@ -231,24 +346,86 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
</p>
</div>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
<div className="flex flex-wrap gap-2">
<Link
to="/inventory/sku-master"
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
SKU master
</Link>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">SKU</span>
<input
value={form.sku}
onChange={(event) => updateField("sku", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<div className="block 2xl:col-span-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="block text-sm font-semibold text-text">SKU builder</span>
<Link to="/inventory/sku-master" className="text-xs font-semibold text-brand">
Manage SKU tree
</Link>
</div>
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select
value={form.skuBuilder?.familyId ?? ""}
onChange={(event) => updateSkuFamily(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">Select family</option>
{skuFamilies.filter((family) => family.isActive).map((family) => (
<option key={family.id} value={family.id}>
{family.code} - {family.name}
</option>
))}
</select>
</label>
{skuLevelOptions.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{skuLevelOptions.map((options, levelIndex) => (
<label key={`sku-level-${levelIndex}`} className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level {levelIndex + 2}</span>
<select
value={selectedSkuNodeIds[levelIndex] ?? ""}
onChange={(event) => updateSkuNode(levelIndex, event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">Stop at this level</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.code} - {option.label}
</option>
))}
</select>
</label>
))}
</div>
) : null}
<div className="rounded-2xl border border-line/70 bg-surface px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Generated SKU</div>
<div className="mt-2 text-lg font-bold text-text">{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}</div>
<div className="mt-2 text-xs text-muted">
{skuPreview
? `${skuPreview.familyCode} branch with ${skuPreview.sequenceCode}${String(skuPreview.nextSequenceNumber).padStart(4, "0")} next in sequence.`
: form.skuBuilder?.familyId
? `Building from ${getSkuFamilyName(form.skuBuilder.familyId)}.`
: "SKU suffix is family-scoped and assigned by the server when the item is saved."}
</div>
</div>
<input
value={form.sku}
readOnly
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none opacity-80"
/>
</div>
</div>
<label className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
<input