sku builder first test
This commit is contained in:
@@ -36,6 +36,12 @@ import type {
|
||||
InventoryItemDetailDto,
|
||||
InventoryItemInput,
|
||||
InventoryItemOptionDto,
|
||||
InventorySkuBuilderPreviewDto,
|
||||
InventorySkuCatalogTreeDto,
|
||||
InventorySkuFamilyDto,
|
||||
InventorySkuFamilyInput,
|
||||
InventorySkuNodeDto,
|
||||
InventorySkuNodeInput,
|
||||
InventoryReservationInput,
|
||||
InventoryItemStatus,
|
||||
InventoryItemSummaryDto,
|
||||
@@ -430,6 +436,38 @@ export const api = {
|
||||
getInventoryItemOptions(token: string) {
|
||||
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
|
||||
},
|
||||
getInventorySkuFamilies(token: string) {
|
||||
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
|
||||
},
|
||||
getInventorySkuCatalog(token: string) {
|
||||
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
|
||||
},
|
||||
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
|
||||
return request<InventorySkuNodeDto[]>(
|
||||
`/api/v1/inventory/sku/nodes${buildQueryString({
|
||||
familyId,
|
||||
parentNodeId: parentNodeId ?? undefined,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
|
||||
return request<InventorySkuBuilderPreviewDto>(
|
||||
`/api/v1/inventory/sku/preview${buildQueryString({
|
||||
familyId,
|
||||
nodeId: nodeId ?? undefined,
|
||||
})}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
},
|
||||
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
|
||||
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
|
||||
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
getWarehouseLocationOptions(token: string) {
|
||||
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
|
||||
},
|
||||
|
||||
@@ -44,6 +44,9 @@ const InventoryDetailPage = React.lazy(() =>
|
||||
const InventoryFormPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
|
||||
);
|
||||
const InventorySkuMasterPage = React.lazy(() =>
|
||||
import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
|
||||
);
|
||||
const WarehousesPage = React.lazy(() =>
|
||||
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
|
||||
);
|
||||
@@ -148,6 +151,7 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
|
||||
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
|
||||
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
|
||||
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
|
||||
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,9 +51,14 @@ export function InventoryListPage() {
|
||||
</p>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
New item
|
||||
</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-3 py-2 text-sm font-semibold text-text">
|
||||
SKU master
|
||||
</Link>
|
||||
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
|
||||
New item
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">
|
||||
|
||||
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeDto, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
|
||||
const emptyFamilyForm: InventorySkuFamilyInput = {
|
||||
code: "",
|
||||
sequenceCode: "",
|
||||
name: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const emptyNodeForm: InventorySkuNodeInput = {
|
||||
familyId: "",
|
||||
parentNodeId: null,
|
||||
code: "",
|
||||
label: "",
|
||||
description: "",
|
||||
sortOrder: 10,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
export function InventorySkuMasterPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [catalog, setCatalog] = useState<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
|
||||
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
|
||||
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(emptyNodeForm);
|
||||
const [selectedFamilyId, setSelectedFamilyId] = useState("");
|
||||
const [status, setStatus] = useState("Loading SKU master...");
|
||||
|
||||
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getInventorySkuCatalog(token)
|
||||
.then((nextCatalog) => {
|
||||
setCatalog(nextCatalog);
|
||||
const firstFamilyId = nextCatalog.families[0]?.id ?? "";
|
||||
setSelectedFamilyId((current) => current || firstFamilyId);
|
||||
setNodeForm((current) => ({
|
||||
...current,
|
||||
familyId: current.familyId || firstFamilyId,
|
||||
}));
|
||||
setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const familyNodes = useMemo(
|
||||
() =>
|
||||
catalog.nodes
|
||||
.filter((node) => node.familyId === selectedFamilyId)
|
||||
.sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
|
||||
[catalog.nodes, selectedFamilyId]
|
||||
);
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => familyNodes.filter((node) => node.level < 6),
|
||||
[familyNodes]
|
||||
);
|
||||
|
||||
function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
|
||||
const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
|
||||
if (!branchNodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return branchNodes.map((node) => (
|
||||
<div key={node.id} className="space-y-2">
|
||||
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3" style={{ marginLeft: `${depth * 16}px` }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text">{node.code} <span className="text-muted">- {node.label}</span></div>
|
||||
<div className="mt-1 text-xs text-muted">Level {node.level} • {node.childCount} child branch(es)</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNodeForm((current) => ({
|
||||
...current,
|
||||
familyId: selectedFamilyId,
|
||||
parentNodeId: node.id,
|
||||
}))
|
||||
}
|
||||
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
|
||||
>
|
||||
Add child
|
||||
</button>
|
||||
</div>
|
||||
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
|
||||
</div>
|
||||
{renderNodes(node.id, depth + 1)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
async function reloadCatalog() {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCatalog = await api.getInventorySkuCatalog(token);
|
||||
setCatalog(nextCatalog);
|
||||
if (!selectedFamilyId && nextCatalog.families[0]) {
|
||||
setSelectedFamilyId(nextCatalog.families[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFamily(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !canManage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await api.createInventorySkuFamily(token, familyForm);
|
||||
setFamilyForm(emptyFamilyForm);
|
||||
setSelectedFamilyId(created.id);
|
||||
setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
|
||||
await reloadCatalog();
|
||||
setStatus(`Created SKU family ${created.code}.`);
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateNode(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token || !canManage || !nodeForm.familyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await api.createInventorySkuNode(token, nodeForm);
|
||||
setNodeForm((current) => ({
|
||||
...emptyNodeForm,
|
||||
familyId: current.familyId,
|
||||
parentNodeId: created.parentNodeId,
|
||||
}));
|
||||
await reloadCatalog();
|
||||
setStatus(`Created SKU branch ${created.code}.`);
|
||||
} catch (error: unknown) {
|
||||
setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Master Data</p>
|
||||
<h3 className="mt-2 text-xl font-bold text-text">SKU Master Builder</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.</p>
|
||||
</div>
|
||||
<Link to="/inventory/items" 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 items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="text-sm font-semibold text-text">Families</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{catalog.families.length === 0 ? (
|
||||
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">No SKU families defined yet.</div>
|
||||
) : (
|
||||
catalog.families.map((family) => (
|
||||
<button
|
||||
key={family.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFamilyId(family.id);
|
||||
setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
|
||||
}}
|
||||
className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
|
||||
selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold text-text">{family.code} <span className="text-muted">({family.sequenceCode})</span></div>
|
||||
<div className="mt-1 text-xs text-muted">{family.name}</div>
|
||||
<div className="mt-2 text-xs text-muted">{family.childNodeCount} branch nodes • next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canManage ? (
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="text-sm font-semibold text-text">Add family</div>
|
||||
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
|
||||
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
|
||||
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
|
||||
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text">Branch tree</div>
|
||||
<div className="mt-1 text-xs text-muted">{status}</div>
|
||||
</div>
|
||||
{selectedFamilyId ? (
|
||||
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canManage ? (
|
||||
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||
<div className="text-sm font-semibold text-text">Add branch node</div>
|
||||
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
|
||||
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
<option value="">Select family</option>
|
||||
{catalog.families.map((family) => (
|
||||
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
|
||||
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: event.target.value || null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
|
||||
<option value="">Family root</option>
|
||||
{parentOptions.map((node) => (
|
||||
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
|
||||
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: 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>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
|
||||
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: 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>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
|
||||
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
|
||||
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: Number(event.target.value) || 0 }))} 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>
|
||||
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||
|
||||
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||
sku: "",
|
||||
skuBuilder: null,
|
||||
name: "",
|
||||
description: "",
|
||||
type: "PURCHASED",
|
||||
|
||||
Reference in New Issue
Block a user