299 lines
15 KiB
TypeScript
299 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|