From 2718e8b4b1ab08e77ba2f4ddefa613910b923ee1 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 22:17:58 -0500 Subject: [PATCH] sku builder first test --- AGENTS.md | 1 + CHANGELOG.md | 1 + INSTRUCTIONS.md | 1 + README.md | 1 + client/src/lib/api.ts | 38 + client/src/main.tsx | 4 + .../modules/inventory/InventoryFormPage.tsx | 207 +++++- .../modules/inventory/InventoryListPage.tsx | 11 +- .../inventory/InventorySkuMasterPage.tsx | 298 ++++++++ client/src/modules/inventory/config.ts | 1 + .../migration.sql | 78 ++ server/prisma/schema.prisma | 43 ++ server/src/modules/inventory/router.ts | 108 +++ server/src/modules/inventory/service.ts | 664 ++++++++++++++++-- shared/src/inventory/types.ts | 81 +++ 15 files changed, 1463 insertions(+), 74 deletions(-) create mode 100644 client/src/modules/inventory/InventorySkuMasterPage.tsx create mode 100644 server/prisma/migrations/20260315233000_inventory_sku_builder/migration.sql diff --git a/AGENTS.md b/AGENTS.md index 331baa2..9aa699e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a - CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments - inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing - inventory transfers, reservations, available-stock visibility, and work-order reservation automation +- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management - sales quotes, sales orders, approvals, revision history/comparison, and purchase orders - purchase-order revision history and revision comparison across document and receipt changes - purchase-order supporting documents and vendor-side purchasing visibility diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba12af..5bda8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This file is the running release and change log for MRP Codex. Keep it updated w ### Added +- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form - Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs - Purchase-order revision snapshots covering document edits, status changes, and receipt posting - Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 3c1986e..f0d33f4 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -16,6 +16,7 @@ This repository implements the platform foundation milestone: - CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata - inventory master data, BOM, warehouse, stock-location, transactions, and item attachments - inventory transfers, reservations, available-stock visibility, and work-order reservation automation +- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management - sales quotes and sales orders with quick actions and quote conversion - sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders - purchase orders with quick actions and searchable vendor/SKU entry diff --git a/README.md b/README.md index 78498da..37fd804 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Current foundation scope includes: - CRM contact history, account contacts, and shared attachments - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory transfers, reservations, and available-stock visibility +- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management - sales quotes and sales orders with searchable customer and SKU entry - sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 41da386..7928f0c 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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("/api/v1/inventory/items/options", undefined, token); }, + getInventorySkuFamilies(token: string) { + return request("/api/v1/inventory/sku/families", undefined, token); + }, + getInventorySkuCatalog(token: string) { + return request("/api/v1/inventory/sku/catalog", undefined, token); + }, + getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) { + return request( + `/api/v1/inventory/sku/nodes${buildQueryString({ + familyId, + parentNodeId: parentNodeId ?? undefined, + })}`, + undefined, + token + ); + }, + getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) { + return request( + `/api/v1/inventory/sku/preview${buildQueryString({ + familyId, + nodeId: nodeId ?? undefined, + })}`, + undefined, + token + ); + }, + createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) { + return request("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token); + }, + createInventorySkuNode(token: string, payload: InventorySkuNodeInput) { + return request("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token); + }, getWarehouseLocationOptions(token: string) { return request("/api/v1/inventory/locations/options", undefined, token); }, diff --git a/client/src/main.tsx b/client/src/main.tsx index 13de16a..b22a78a 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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() }, { path: "/inventory/items/:itemId", element: lazyElement() }, + { path: "/inventory/sku-master", element: lazyElement() }, { path: "/inventory/warehouses", element: lazyElement() }, { path: "/inventory/warehouses/:warehouseId", element: lazyElement() }, ], diff --git a/client/src/modules/inventory/InventoryFormPage.tsx b/client/src/modules/inventory/InventoryFormPage.tsx index 5cbe7f4..3f0e899 100644 --- a/client/src/modules/inventory/InventoryFormPage.tsx +++ b/client/src/modules/inventory/InventoryFormPage.tsx @@ -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([]); + const [skuLevelOptions, setSkuLevelOptions] = useState([]); + const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState>([]); + const [skuPreview, setSkuPreview] = useState(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: 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.

- - Cancel - +
+ + SKU master + + + Cancel + +
- +
+
+ SKU builder + + Manage SKU tree + +
+
+ + {skuLevelOptions.length > 0 ? ( +
+ {skuLevelOptions.map((options, levelIndex) => ( + + ))} +
+ ) : null} +
+
Generated SKU
+
{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}
+
+ {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."} +
+
+ +
+
{canManage ? ( - - New item - +
+ + SKU master + + + New item + +
) : null}
diff --git a/client/src/modules/inventory/InventorySkuMasterPage.tsx b/client/src/modules/inventory/InventorySkuMasterPage.tsx new file mode 100644 index 0000000..10340b4 --- /dev/null +++ b/client/src/modules/inventory/InventorySkuMasterPage.tsx @@ -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({ families: [], nodes: [] }); + const [familyForm, setFamilyForm] = useState(emptyFamilyForm); + const [nodeForm, setNodeForm] = useState(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) => ( +
+
+
+
+
{node.code} - {node.label}
+
Level {node.level} • {node.childCount} child branch(es)
+
+ +
+ {node.description ?
{node.description}
: null} +
+ {renderNodes(node.id, depth + 1)} +
+ )); + } + + 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) { + 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) { + 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 ( +
+
+
+
+

Inventory Master Data

+

SKU Master Builder

+

Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.

+
+ + Back to items + +
+
+ +
+
+
+
Families
+
+ {catalog.families.length === 0 ? ( +
No SKU families defined yet.
+ ) : ( + catalog.families.map((family) => ( + + )) + )} +
+
+ + {canManage ? ( +
+
Add family
+
+
+ + +
+ +