import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared"; import { permissions } from "@mrp/shared"; 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 { useNavigate, useParams } from "react-router-dom"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; import { useAuth } from "../../auth/AuthProvider"; import { api, ApiError } from "../../lib/api"; import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryThumbnailOwnerType, inventoryTypeOptions, inventoryUnitOptions } from "./config"; interface InventoryFormPageProps { mode: "create" | "edit"; } export function InventoryFormPage({ mode }: InventoryFormPageProps) { const navigate = useNavigate(); const { token, user } = useAuth(); const { itemId } = useParams(); const [form, setForm] = useState(emptyInventoryItemInput); const [componentOptions, setComponentOptions] = useState([]); const [stations, setStations] = useState([]); const [vendorOptions, setVendorOptions] = useState([]); const [componentSearchTerms, setComponentSearchTerms] = useState([]); const [activeComponentPicker, setActiveComponentPicker] = useState(null); const [vendorSearchTerm, setVendorSearchTerm] = useState(""); const [vendorPickerOpen, setVendorPickerOpen] = useState(false); 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); const [thumbnailAttachment, setThumbnailAttachment] = useState(null); const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState(null); const [pendingThumbnailFile, setPendingThumbnailFile] = useState(null); const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false); function getComponentOption(componentItemId: string) { return componentOptions.find((option) => option.id === componentItemId) ?? null; } function getComponentSku(componentItemId: string) { return getComponentOption(componentItemId)?.sku ?? ""; } function getSkuFamilyName(familyId: string | null) { if (!familyId) { return ""; } return skuFamilies.find((family) => family.id === familyId)?.name ?? ""; } function replaceThumbnailPreview(nextUrl: string | null) { setThumbnailPreviewUrl((current) => { if (current) { window.URL.revokeObjectURL(current); } return nextUrl; }); } const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false; const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false; useEffect(() => { if (!token) { return; } api .getInventoryItemOptions(token) .then((options) => { const nextOptions = options.filter((option) => option.id !== itemId); setComponentOptions(nextOptions); setComponentSearchTerms((current) => form.bomLines.map((line, index) => { if (current[index]?.trim()) { return current[index]; } const match = nextOptions.find((option) => option.id === line.componentItemId); return match ? match.sku : ""; }) ); }) .catch(() => setComponentOptions([])); }, [form.bomLines, itemId, token]); useEffect(() => { if (mode !== "edit" || !token || !itemId) { return; } api .getInventoryItem(token, itemId) .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, status: item.status, unitOfMeasure: item.unitOfMeasure, isSellable: item.isSellable, isPurchasable: item.isPurchasable, preferredVendorId: item.preferredVendorId, defaultCost: item.defaultCost, defaultPrice: item.defaultPrice, notes: item.notes, bomLines: item.bomLines.map((line) => ({ componentItemId: line.componentItemId, quantity: line.quantity, unitOfMeasure: line.unitOfMeasure, notes: line.notes, position: line.position, })), operations: item.operations.map((operation) => ({ stationId: operation.stationId, setupMinutes: operation.setupMinutes, runMinutesPerUnit: operation.runMinutesPerUnit, moveMinutes: operation.moveMinutes, position: operation.position, 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 ?? ""); }) .catch((error: unknown) => { const message = error instanceof ApiError ? error.message : "Unable to load inventory item."; setStatus(message); }); }, [itemId, mode, token]); useEffect(() => { if (!token) { return; } api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([])); api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([])); }, [token]); useEffect(() => { return () => { if (thumbnailPreviewUrl) { window.URL.revokeObjectURL(thumbnailPreviewUrl); } }; }, [thumbnailPreviewUrl]); useEffect(() => { if (!token || !itemId || !canReadFiles) { setThumbnailAttachment(null); replaceThumbnailPreview(null); return; } let cancelled = false; const activeToken: string = token; const activeItemId: string = itemId; async function loadThumbnail() { const attachments = await api.getAttachments(activeToken, inventoryThumbnailOwnerType, activeItemId); const latestAttachment = attachments[0] ?? null; if (!latestAttachment) { if (!cancelled) { setThumbnailAttachment(null); replaceThumbnailPreview(null); } return; } const blob = await api.getFileContentBlob(activeToken, latestAttachment.id); if (!cancelled) { setThumbnailAttachment(latestAttachment); replaceThumbnailPreview(window.URL.createObjectURL(blob)); } } void loadThumbnail().catch(() => { if (!cancelled) { setThumbnailAttachment(null); replaceThumbnailPreview(null); } }); return () => { cancelled = true; }; }, [canReadFiles, itemId, 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 ""; } return vendorOptions.find((vendor) => vendor.id === vendorId)?.name ?? ""; } function updateBomLine(index: number, nextLine: InventoryBomLineInput) { setForm((current) => ({ ...current, bomLines: current.bomLines.map((line, lineIndex) => (lineIndex === index ? nextLine : line)), })); } function updateComponentSearchTerm(index: number, value: string) { setComponentSearchTerms((current) => { const nextTerms = [...current]; nextTerms[index] = value; return nextTerms; }); } function addBomLine() { setForm((current) => ({ ...current, bomLines: [ ...current.bomLines, { ...emptyInventoryBomLineInput, position: current.bomLines.length === 0 ? 10 : Math.max(...current.bomLines.map((line) => line.position)) + 10, }, ], })); setComponentSearchTerms((current) => [...current, ""]); } function updateOperation(index: number, nextOperation: InventoryItemOperationInput) { setForm((current) => ({ ...current, operations: current.operations.map((operation, operationIndex) => (operationIndex === index ? nextOperation : operation)), })); } function addOperation() { setForm((current) => ({ ...current, operations: [ ...current.operations, { ...emptyInventoryOperationInput, position: current.operations.length === 0 ? 10 : Math.max(...current.operations.map((operation) => operation.position)) + 10, }, ], })); } function removeOperation(index: number) { setForm((current) => ({ ...current, operations: current.operations.filter((_operation, operationIndex) => operationIndex !== index), })); } function removeBomLine(index: number) { setForm((current) => ({ ...current, bomLines: current.bomLines.filter((_line, lineIndex) => lineIndex !== index), })); setComponentSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index)); setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current)); } function handleThumbnailSelect(event: React.ChangeEvent) { const file = event.target.files?.[0]; if (!file) { return; } setPendingThumbnailFile(file); setRemoveThumbnailOnSave(false); replaceThumbnailPreview(window.URL.createObjectURL(file)); event.target.value = ""; } function clearThumbnailSelection() { setPendingThumbnailFile(null); setRemoveThumbnailOnSave(true); replaceThumbnailPreview(null); } async function syncThumbnail(savedItemId: string) { if (!token || !canWriteFiles) { return; } if (thumbnailAttachment && (removeThumbnailOnSave || pendingThumbnailFile)) { await api.deleteAttachment(token, thumbnailAttachment.id); } if (pendingThumbnailFile) { const uploaded = await api.uploadFile(token, pendingThumbnailFile, inventoryThumbnailOwnerType, savedItemId); setThumbnailAttachment(uploaded); const blob = await api.getFileContentBlob(token, uploaded.id); replaceThumbnailPreview(window.URL.createObjectURL(blob)); } else if (removeThumbnailOnSave) { setThumbnailAttachment(null); } setPendingThumbnailFile(null); setRemoveThumbnailOnSave(false); } const pendingRemovalDetail = pendingRemoval ? pendingRemoval.kind === "operation" ? { label: form.operations[pendingRemoval.index]?.stationId || "this routing operation", typeLabel: "routing operation" } : { label: getComponentSku(form.bomLines[pendingRemoval.index]?.componentItemId ?? "") || "this BOM line", typeLabel: "BOM line" } : null; async function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (!token) { return; } setIsSaving(true); setStatus("Saving inventory item..."); try { const saved = mode === "create" ? await api.createInventoryItem(token, form) : await api.updateInventoryItem(token, itemId ?? "", form); await syncThumbnail(saved.id); navigate(`/inventory/items/${saved.id}`); } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to save inventory item."; setStatus(message); setIsSaving(false); } } function forceNavigate(path: string) { window.location.assign(path); } function openSkuMaster() { forceNavigate("/inventory/sku-master"); } function closeEditor() { forceNavigate(mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`); } return (

INVENTORY EDITOR

{mode === "create" ? "NEW ITEM" : "EDIT ITEM"}

SKU BUILDER
{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."}
Thumbnail
{thumbnailPreviewUrl ? ( Inventory thumbnail preview ) : (
No thumbnail selected
)}
{canWriteFiles ? ( ) : null} {(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? ( ) : null}
Thumbnail attachment
{pendingThumbnailFile ? `${pendingThumbnailFile.name} will upload when you save this item.` : removeThumbnailOnSave ? "Thumbnail removal is staged and will apply when you save." : thumbnailAttachment ? `${thumbnailAttachment.originalName} is attached as the current item thumbnail.` : "Attach a product image, render, or reference photo for this item."}