sku builder first test
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user