Files
mrp/client/src/modules/inventory/InventoryFormPage.tsx
T

1017 lines
47 KiB
TypeScript
Raw Normal View History

2026-03-15 22:51:35 -05:00
import type { FileAttachmentDto, PurchaseVendorOptionDto } from "@mrp/shared";
import { permissions } from "@mrp/shared";
2026-03-15 22:17:58 -05:00
import type {
InventoryBomLineInput,
InventoryItemInput,
InventoryItemOperationInput,
InventoryItemOptionDto,
InventorySkuBuilderPreviewDto,
InventorySkuFamilyDto,
InventorySkuNodeDto,
} from "@mrp/shared/dist/inventory/types.js";
2026-03-15 12:11:46 -05:00
import type { ManufacturingStationDto } from "@mrp/shared";
2026-03-14 21:10:35 -05:00
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
2026-03-15 19:40:35 -05:00
import { ConfirmActionDialog } from "../../components/ConfirmActionDialog";
2026-03-14 21:10:35 -05:00
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
2026-03-15 22:51:35 -05:00
import { emptyInventoryBomLineInput, emptyInventoryItemInput, emptyInventoryOperationInput, inventoryStatusOptions, inventoryThumbnailOwnerType, inventoryTypeOptions, inventoryUnitOptions } from "./config";
2026-03-14 21:10:35 -05:00
interface InventoryFormPageProps {
mode: "create" | "edit";
}
export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const navigate = useNavigate();
2026-03-15 22:51:35 -05:00
const { token, user } = useAuth();
2026-03-14 21:10:35 -05:00
const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
2026-03-15 12:11:46 -05:00
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
2026-03-15 16:40:25 -05:00
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
2026-03-14 21:31:40 -05:00
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
2026-03-15 16:40:25 -05:00
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
2026-03-14 21:10:35 -05:00
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
2026-03-15 19:40:35 -05:00
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
2026-03-15 22:17:58 -05:00
const [skuFamilies, setSkuFamilies] = useState<InventorySkuFamilyDto[]>([]);
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
2026-03-15 22:51:35 -05:00
const [thumbnailAttachment, setThumbnailAttachment] = useState<FileAttachmentDto | null>(null);
const [thumbnailPreviewUrl, setThumbnailPreviewUrl] = useState<string | null>(null);
const [pendingThumbnailFile, setPendingThumbnailFile] = useState<File | null>(null);
const [removeThumbnailOnSave, setRemoveThumbnailOnSave] = useState(false);
2026-03-14 21:10:35 -05:00
2026-03-14 21:36:42 -05:00
function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null;
}
function getComponentSku(componentItemId: string) {
return getComponentOption(componentItemId)?.sku ?? "";
2026-03-14 21:31:40 -05:00
}
2026-03-15 22:17:58 -05:00
function getSkuFamilyName(familyId: string | null) {
if (!familyId) {
return "";
}
return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
}
2026-03-15 22:51:35 -05:00
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;
2026-03-14 21:10:35 -05:00
useEffect(() => {
if (!token) {
return;
}
api
.getInventoryItemOptions(token)
2026-03-14 21:31:40 -05:00
.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);
2026-03-14 21:36:42 -05:00
return match ? match.sku : "";
2026-03-14 21:31:40 -05:00
})
);
})
2026-03-14 21:10:35 -05:00
.catch(() => setComponentOptions([]));
2026-03-14 21:31:40 -05:00
}, [form.bomLines, itemId, token]);
2026-03-14 21:10:35 -05:00
useEffect(() => {
if (mode !== "edit" || !token || !itemId) {
return;
}
api
.getInventoryItem(token, itemId)
.then((item) => {
setForm({
sku: item.sku,
2026-03-15 22:17:58 -05:00
skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
2026-03-14 21:10:35 -05:00
name: item.name,
description: item.description,
type: item.type,
status: item.status,
unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable,
isPurchasable: item.isPurchasable,
2026-03-15 16:40:25 -05:00
preferredVendorId: item.preferredVendorId,
2026-03-14 21:10:35 -05:00
defaultCost: item.defaultCost,
2026-03-14 23:23:43 -05:00
defaultPrice: item.defaultPrice,
2026-03-14 21:10:35 -05:00
notes: item.notes,
bomLines: item.bomLines.map((line) => ({
componentItemId: line.componentItemId,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
notes: line.notes,
position: line.position,
})),
2026-03-15 12:11:46 -05:00
operations: item.operations.map((operation) => ({
stationId: operation.stationId,
setupMinutes: operation.setupMinutes,
runMinutesPerUnit: operation.runMinutesPerUnit,
moveMinutes: operation.moveMinutes,
position: operation.position,
notes: operation.notes,
})),
2026-03-14 21:10:35 -05:00
});
2026-03-15 22:17:58 -05:00
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);
2026-03-14 21:36:42 -05:00
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
2026-03-14 21:10:35 -05:00
setStatus("Inventory item loaded.");
2026-03-15 16:40:25 -05:00
setVendorSearchTerm(item.preferredVendorName ?? "");
2026-03-14 21:10:35 -05:00
})
.catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
}, [itemId, mode, token]);
2026-03-15 12:11:46 -05:00
useEffect(() => {
if (!token) {
return;
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
2026-03-15 16:40:25 -05:00
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
2026-03-15 22:17:58 -05:00
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
2026-03-15 12:11:46 -05:00
}, [token]);
2026-03-15 22:51:35 -05:00
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]);
2026-03-15 22:17:58 -05:00
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]);
2026-03-14 21:10:35 -05:00
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
2026-03-15 22:17:58 -05:00
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;
});
}
2026-03-15 16:40:25 -05:00
function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) {
return "";
}
return vendorOptions.find((vendor) => vendor.id === vendorId)?.name ?? "";
}
2026-03-14 21:10:35 -05:00
function updateBomLine(index: number, nextLine: InventoryBomLineInput) {
setForm((current) => ({
...current,
bomLines: current.bomLines.map((line, lineIndex) => (lineIndex === index ? nextLine : line)),
}));
}
2026-03-14 21:31:40 -05:00
function updateComponentSearchTerm(index: number, value: string) {
setComponentSearchTerms((current) => {
const nextTerms = [...current];
nextTerms[index] = value;
return nextTerms;
});
}
2026-03-14 21:10:35 -05:00
function addBomLine() {
setForm((current) => ({
...current,
bomLines: [
...current.bomLines,
{
...emptyInventoryBomLineInput,
position: current.bomLines.length === 0 ? 10 : Math.max(...current.bomLines.map((line) => line.position)) + 10,
},
],
}));
2026-03-14 21:31:40 -05:00
setComponentSearchTerms((current) => [...current, ""]);
2026-03-14 21:10:35 -05:00
}
2026-03-15 12:11:46 -05:00
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),
}));
}
2026-03-14 21:10:35 -05:00
function removeBomLine(index: number) {
setForm((current) => ({
...current,
bomLines: current.bomLines.filter((_line, lineIndex) => lineIndex !== index),
}));
2026-03-14 21:31:40 -05:00
setComponentSearchTerms((current) => current.filter((_term, termIndex) => termIndex !== index));
setActiveComponentPicker((current) => (current === index ? null : current != null && current > index ? current - 1 : current));
2026-03-14 21:10:35 -05:00
}
2026-03-15 22:51:35 -05:00
function handleThumbnailSelect(event: React.ChangeEvent<HTMLInputElement>) {
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);
}
2026-03-15 19:40:35 -05:00
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;
2026-03-14 21:10:35 -05:00
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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);
2026-03-15 22:51:35 -05:00
await syncThumbnail(saved.id);
2026-03-14 21:10:35 -05:00
navigate(`/inventory/items/${saved.id}`);
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save inventory item.";
setStatus(message);
setIsSaving(false);
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
2026-03-15 20:07:48 -05:00
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
2026-03-14 22:21:31 -05:00
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
2026-03-14 21:10:35 -05:00
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Editor</p>
2026-03-14 22:21:31 -05:00
<h3 className="mt-2 text-xl font-bold text-text">{mode === "create" ? "New Item" : "Edit Item"}</h3>
2026-03-14 21:10:35 -05:00
<p className="mt-2 max-w-2xl text-sm text-muted">
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
</p>
</div>
2026-03-15 22:17:58 -05:00
<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>
2026-03-14 21:10:35 -05:00
</div>
</section>
2026-03-15 20:07:48 -05:00
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
2026-03-14 22:21:31 -05:00
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
2026-03-15 22:17:58 -05:00
<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>
2026-03-14 21:10:35 -05:00
<label className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
<input
value={form.name}
onChange={(event) => updateField("name", event.target.value)}
2026-03-14 22:08:29 -05:00
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Default cost</span>
<input
type="number"
min={0}
step={0.01}
value={form.defaultCost ?? ""}
onChange={(event) => updateField("defaultCost", event.target.value === "" ? null : Number(event.target.value))}
2026-03-14 22:08:29 -05:00
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 23:23:43 -05:00
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Default price</span>
<input
type="number"
min={0}
step={0.01}
value={form.defaultPrice ?? ""}
onChange={(event) => updateField("defaultPrice", event.target.value === "" ? null : Number(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"
2026-03-14 21:10:35 -05:00
/>
</label>
</div>
2026-03-15 22:51:35 -05:00
<div className="grid gap-4 xl:grid-cols-[220px_1fr]">
<div className="space-y-3">
<div>
<span className="mb-2 block text-sm font-semibold text-text">Thumbnail</span>
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-[18px] border border-line/70 bg-page/70">
{thumbnailPreviewUrl ? (
<img src={thumbnailPreviewUrl} alt="Inventory thumbnail preview" className="h-full w-full object-cover" />
) : (
<div className="px-4 text-center text-xs text-muted">No thumbnail selected</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canWriteFiles ? (
<label className="inline-flex cursor-pointer items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
Choose image
<input className="hidden" type="file" accept="image/*" onChange={handleThumbnailSelect} />
</label>
) : null}
{(thumbnailPreviewUrl || thumbnailAttachment) && canWriteFiles ? (
<button type="button" onClick={clearThumbnailSelection} className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Remove
</button>
) : null}
</div>
</div>
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="text-sm font-semibold text-text">Thumbnail attachment</div>
<div className="mt-2 text-sm text-muted">
{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."}
</div>
<div className="mt-3 text-xs text-muted">
Supported by the existing file-attachment system. The thumbnail is stored separately from general item documents so the item editor can treat it as the primary visual.
</div>
</div>
</div>
2026-03-14 22:21:31 -05:00
<div className="grid gap-3 xl:grid-cols-4">
2026-03-14 21:10:35 -05:00
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Type</span>
<select
value={form.type}
onChange={(event) => updateField("type", event.target.value as InventoryItemInput["type"])}
2026-03-14 22:08:29 -05:00
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
>
{inventoryTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>
<select
value={form.status}
onChange={(event) => updateField("status", event.target.value as InventoryItemInput["status"])}
2026-03-14 22:08:29 -05:00
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
>
{inventoryStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Unit of measure</span>
<select
value={form.unitOfMeasure}
onChange={(event) => updateField("unitOfMeasure", event.target.value as InventoryItemInput["unitOfMeasure"])}
2026-03-14 22:08:29 -05:00
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
>
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="grid gap-3 sm:grid-cols-2">
2026-03-14 22:21:31 -05:00
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
2026-03-14 21:10:35 -05:00
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
<span className="text-sm font-semibold text-text">Sellable</span>
</label>
2026-03-14 22:21:31 -05:00
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
2026-03-14 21:10:35 -05:00
<input
type="checkbox"
checked={form.isPurchasable}
onChange={(event) => updateField("isPurchasable", event.target.checked)}
/>
<span className="text-sm font-semibold text-text">Purchasable</span>
</label>
</div>
</div>
2026-03-15 16:40:25 -05:00
<label className="block xl:max-w-xl">
<span className="mb-2 block text-sm font-semibold text-text">Preferred vendor</span>
<div className="relative">
<input
value={vendorSearchTerm}
onChange={(event) => {
setVendorSearchTerm(event.target.value);
updateField("preferredVendorId", null);
setVendorPickerOpen(true);
}}
onFocus={() => setVendorPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setVendorPickerOpen(false);
if (form.preferredVendorId) {
setVendorSearchTerm(getSelectedVendorName(form.preferredVendorId));
}
}, 120);
}}
disabled={!form.isPurchasable}
placeholder={form.isPurchasable ? "Search vendor" : "Enable purchasable to assign sourcing"}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
/>
{vendorPickerOpen && form.isPurchasable ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("preferredVendorId", null);
setVendorSearchTerm("");
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"
>
<div className="font-semibold text-text">No preferred vendor</div>
</button>
{vendorOptions
.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((vendor) => (
<button
key={vendor.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("preferredVendorId", vendor.id);
setVendorSearchTerm(vendor.name);
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{vendor.name}</div>
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
</button>
))}
</div>
) : null}
</div>
<div className="mt-2 text-xs text-muted">
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Demand planning uses this vendor when creating buy recommendations."}
</div>
</label>
2026-03-14 21:10:35 -05:00
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea
value={form.description}
onChange={(event) => updateField("description", event.target.value)}
rows={4}
2026-03-15 20:07:48 -05:00
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Internal notes</span>
<textarea
value={form.notes}
onChange={(event) => updateField("notes", event.target.value)}
rows={4}
2026-03-15 20:07:48 -05:00
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
</section>
2026-03-15 12:11:46 -05:00
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
2026-03-15 20:07:48 -05:00
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
2026-03-15 12:11:46 -05:00
<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">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station and time template</h4>
2026-03-17 23:35:37 -05:00
<p className="mt-2 text-sm text-muted">These operations are copied automatically into work orders and feed the planning workbench without manual planner task entry.</p>
2026-03-15 12:11:46 -05:00
</div>
<button type="button" onClick={addOperation} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Add operation
</button>
</div>
{form.operations.length === 0 ? (
2026-03-15 20:07:48 -05:00
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
2026-03-15 12:11:46 -05:00
Add at least one station operation for this buildable item.
</div>
) : (
<div className="mt-5 space-y-4">
{form.operations.map((operation, index) => (
2026-03-15 20:07:48 -05:00
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
2026-03-15 12:11:46 -05:00
<div className="grid gap-3 xl:grid-cols-[1.2fr_0.55fr_0.7fr_0.55fr_0.55fr_auto]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Station</span>
<select
value={operation.stationId}
onChange={(event) => updateOperation(index, { ...operation, stationId: 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 station</option>
{stations.filter((station) => station.isActive).map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Setup</span>
<input type="number" min={0} step={1} value={operation.setupMinutes} onChange={(event) => updateOperation(index, { ...operation, setupMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface 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">Run / Unit</span>
<input type="number" min={0} step={1} value={operation.runMinutesPerUnit} onChange={(event) => updateOperation(index, { ...operation, runMinutesPerUnit: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface 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">Move</span>
<input type="number" min={0} step={1} value={operation.moveMinutes} onChange={(event) => updateOperation(index, { ...operation, moveMinutes: Number.parseInt(event.target.value, 10) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface 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">Position</span>
<input type="number" min={0} step={10} value={operation.position} onChange={(event) => updateOperation(index, { ...operation, position: Number(event.target.value) || 0 })} className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="flex items-end">
2026-03-15 19:40:35 -05:00
<button type="button" onClick={() => setPendingRemoval({ kind: "operation", index })} className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300">
2026-03-15 12:11:46 -05:00
Remove
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input value={operation.notes} onChange={(event) => updateOperation(index, { ...operation, notes: 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" />
</label>
</div>
))}
</div>
)}
</section>
) : null}
2026-03-15 20:07:48 -05:00
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
2026-03-14 22:21:31 -05:00
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
2026-03-14 21:10:35 -05:00
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Bill Of Materials</p>
2026-03-14 22:21:31 -05:00
<h4 className="mt-2 text-lg font-bold text-text">Component lines</h4>
2026-03-14 21:10:35 -05:00
<p className="mt-2 text-sm text-muted">Add BOM components for manufactured or assembly items. Purchased and service items can be saved without BOM lines.</p>
</div>
<button
type="button"
onClick={addBomLine}
2026-03-14 22:21:31 -05:00
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
2026-03-14 21:10:35 -05:00
>
Add BOM line
</button>
</div>
{form.bomLines.length === 0 ? (
2026-03-15 20:07:48 -05:00
<div className="mt-5 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
2026-03-14 21:10:35 -05:00
No BOM lines added yet.
</div>
) : (
<div className="mt-5 space-y-4">
{form.bomLines.map((line, index) => (
2026-03-15 20:07:48 -05:00
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
2026-03-14 22:21:31 -05:00
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
2026-03-14 21:10:35 -05:00
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
2026-03-14 21:31:40 -05:00
<div className="relative">
<input
2026-03-14 21:36:42 -05:00
value={componentSearchTerms[index] ?? getComponentSku(line.componentItemId)}
2026-03-14 21:31:40 -05:00
onChange={(event) => {
updateComponentSearchTerm(index, event.target.value);
updateBomLine(index, { ...line, componentItemId: "" });
setActiveComponentPicker(index);
}}
onFocus={() => setActiveComponentPicker(index)}
onBlur={() => {
window.setTimeout(() => {
setActiveComponentPicker((current) => (current === index ? null : current));
if (!form.bomLines[index]?.componentItemId) {
updateComponentSearchTerm(index, "");
} else {
2026-03-14 21:36:42 -05:00
updateComponentSearchTerm(index, getComponentSku(form.bomLines[index].componentItemId));
2026-03-14 21:31:40 -05:00
}
}, 120);
}}
2026-03-14 21:36:42 -05:00
placeholder="Search by SKU"
2026-03-14 22:21:31 -05:00
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:31:40 -05:00
/>
2026-03-14 21:36:42 -05:00
<div className="mt-2 min-h-5 text-xs text-muted">
{getComponentOption(line.componentItemId)?.name ?? "No component selected"}
</div>
2026-03-14 21:31:40 -05:00
{activeComponentPicker === index ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
{componentOptions
.filter((option) => {
const query = (componentSearchTerms[index] ?? "").trim().toLowerCase();
if (!query) {
return true;
}
2026-03-14 21:36:42 -05:00
return option.sku.toLowerCase().includes(query);
2026-03-14 21:31:40 -05:00
})
.slice(0, 12)
.map((option) => (
<button
key={option.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateBomLine(index, { ...line, componentItemId: option.id });
2026-03-14 21:36:42 -05:00
updateComponentSearchTerm(index, option.sku);
2026-03-14 21:31:40 -05:00
setActiveComponentPicker(null);
}}
2026-03-14 21:40:28 -05:00
title={option.name}
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm font-semibold text-text transition last:border-b-0 hover:bg-page/70"
2026-03-14 21:31:40 -05:00
>
2026-03-14 21:40:28 -05:00
{option.sku}
2026-03-14 21:31:40 -05:00
</button>
))}
{componentOptions.filter((option) => {
const query = (componentSearchTerms[index] ?? "").trim().toLowerCase();
if (!query) {
return true;
}
2026-03-14 21:36:42 -05:00
return option.sku.toLowerCase().includes(query);
2026-03-14 21:31:40 -05:00
}).length === 0 ? (
2026-03-14 22:21:31 -05:00
<div className="px-2 py-2 text-sm text-muted">No matching components found.</div>
2026-03-14 21:31:40 -05:00
) : null}
</div>
) : null}
</div>
2026-03-14 21:10:35 -05:00
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input
type="number"
2026-03-14 22:37:09 -05:00
min={1}
step={1}
2026-03-14 21:10:35 -05:00
value={line.quantity}
2026-03-14 22:37:09 -05:00
onChange={(event) => updateBomLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })}
2026-03-14 22:21:31 -05:00
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">UOM</span>
<select
value={line.unitOfMeasure}
onChange={(event) => updateBomLine(index, { ...line, unitOfMeasure: event.target.value as InventoryBomLineInput["unitOfMeasure"] })}
2026-03-14 22:21:31 -05:00
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
>
{inventoryUnitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Position</span>
<input
type="number"
min={0}
step={10}
value={line.position}
onChange={(event) => updateBomLine(index, { ...line, position: Number(event.target.value) })}
2026-03-14 22:21:31 -05:00
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
<div className="flex items-end">
<button
type="button"
2026-03-15 19:40:35 -05:00
onClick={() => setPendingRemoval({ kind: "bom-line", index })}
2026-03-14 22:21:31 -05:00
className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"
2026-03-14 21:10:35 -05:00
>
Remove
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input
value={line.notes}
onChange={(event) => updateBomLine(index, { ...line, notes: event.target.value })}
2026-03-14 22:21:31 -05:00
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
2026-03-14 21:10:35 -05:00
/>
</label>
</div>
))}
</div>
)}
2026-03-14 22:21:31 -05:00
<div className="mt-6 flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
2026-03-14 21:10:35 -05:00
<span className="min-w-0 text-sm text-muted">{status}</span>
<button
type="submit"
disabled={isSaving}
2026-03-14 22:21:31 -05:00
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
2026-03-14 21:10:35 -05:00
>
{isSaving ? "Saving..." : mode === "create" ? "Create item" : "Save changes"}
</button>
</div>
</section>
2026-03-15 19:40:35 -05:00
<ConfirmActionDialog
open={pendingRemoval != null}
title={pendingRemoval?.kind === "operation" ? "Remove routing operation" : "Remove BOM line"}
description={
pendingRemoval && pendingRemovalDetail
? `Remove ${pendingRemovalDetail.label} from the item ${pendingRemovalDetail.typeLabel} draft.`
: "Remove this draft row."
}
impact={
pendingRemoval?.kind === "operation"
? "The operation will no longer be copied into new work orders from this item."
: "The component requirement will be removed from the BOM draft immediately."
}
recovery="Add the row back before saving if this change was accidental."
confirmLabel={pendingRemoval?.kind === "operation" ? "Remove operation" : "Remove BOM line"}
onClose={() => setPendingRemoval(null)}
onConfirm={() => {
if (pendingRemoval?.kind === "operation") {
removeOperation(pendingRemoval.index);
} else if (pendingRemoval?.kind === "bom-line") {
removeBomLine(pendingRemoval.index);
}
setPendingRemoval(null);
}}
/>
2026-03-14 21:10:35 -05:00
</form>
);
}
2026-03-15 20:07:48 -05:00