1023 lines
46 KiB
TypeScript
1023 lines
46 KiB
TypeScript
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<InventoryItemInput>(emptyInventoryItemInput);
|
|
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
|
|
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
|
|
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
|
|
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
|
|
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(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<InventorySkuFamilyDto[]>([]);
|
|
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
|
|
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
|
|
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(null);
|
|
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);
|
|
|
|
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 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 "";
|
|
}
|
|
|
|
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<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);
|
|
}
|
|
|
|
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<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);
|
|
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 (
|
|
<form className="page-stack" onSubmit={handleSubmit}>
|
|
<section className="surface-panel">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<p className="section-kicker">INVENTORY EDITOR</p>
|
|
<h3 className="module-title">{mode === "create" ? "NEW ITEM" : "EDIT ITEM"}</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={openSkuMaster}
|
|
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
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={closeEditor}
|
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section className="surface-panel space-y-3">
|
|
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
|
<div className="block 2xl:col-span-2">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">SKU BUILDER</span>
|
|
<button type="button" onClick={openSkuMaster} className="text-xs font-semibold text-brand">
|
|
Manage SKU tree
|
|
</button>
|
|
</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
|
|
value={form.name}
|
|
onChange={(event) => updateField("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-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))}
|
|
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-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"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<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-xs font-semibold uppercase tracking-[0.18em] text-muted">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>
|
|
</div>
|
|
<div className="grid gap-3 xl:grid-cols-4">
|
|
<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"])}
|
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
>
|
|
{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"])}
|
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
>
|
|
{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"])}
|
|
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
>
|
|
{inventoryUnitOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
|
<input type="checkbox" checked={form.isSellable} onChange={(event) => updateField("isSellable", event.target.checked)} />
|
|
<span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Sellable</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 rounded-2xl border border-line/70 bg-page px-2 py-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.isPurchasable}
|
|
onChange={(event) => updateField("isPurchasable", event.target.checked)}
|
|
/>
|
|
<span className="text-sm font-semibold uppercase tracking-[0.08em] text-text">Purchasable</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<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) : "Used as the default buy source."}
|
|
</div>
|
|
</label>
|
|
<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}
|
|
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-sm font-semibold text-text">Internal notes</span>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={(event) => updateField("notes", event.target.value)}
|
|
rows={4}
|
|
className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
/>
|
|
</label>
|
|
</section>
|
|
{form.type === "ASSEMBLY" || form.type === "MANUFACTURED" ? (
|
|
<section className="surface-panel">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<p className="section-kicker">MANUFACTURING ROUTING</p>
|
|
<h4 className="text-lg font-bold text-text">STATION AND TIME TEMPLATE</h4>
|
|
</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 ? (
|
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
|
Add at least one station operation for this buildable item.
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 space-y-3">
|
|
{form.operations.map((operation, index) => (
|
|
<div key={`${operation.stationId}-${operation.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<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">
|
|
<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">
|
|
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}
|
|
<section className="surface-panel">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<p className="section-kicker">BILL OF MATERIALS</p>
|
|
<h4 className="text-lg font-bold text-text">COMPONENT LINES</h4>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={addBomLine}
|
|
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
|
>
|
|
Add BOM line
|
|
</button>
|
|
</div>
|
|
{form.bomLines.length === 0 ? (
|
|
<div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">
|
|
No BOM lines added yet.
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 space-y-3">
|
|
{form.bomLines.map((line, index) => (
|
|
<div key={`${line.componentItemId}-${line.position}-${index}`} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
|
|
<div className="grid gap-3 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
|
|
<label className="block">
|
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
|
|
<div className="relative">
|
|
<input
|
|
value={componentSearchTerms[index] ?? getComponentSku(line.componentItemId)}
|
|
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 {
|
|
updateComponentSearchTerm(index, getComponentSku(form.bomLines[index].componentItemId));
|
|
}
|
|
}, 120);
|
|
}}
|
|
placeholder="Search by SKU"
|
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
/>
|
|
<div className="mt-2 min-h-5 text-xs text-muted">
|
|
{getComponentOption(line.componentItemId)?.name ?? "No component selected"}
|
|
</div>
|
|
{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;
|
|
}
|
|
|
|
return option.sku.toLowerCase().includes(query);
|
|
})
|
|
.slice(0, 12)
|
|
.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
updateBomLine(index, { ...line, componentItemId: option.id });
|
|
updateComponentSearchTerm(index, option.sku);
|
|
setActiveComponentPicker(null);
|
|
}}
|
|
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"
|
|
>
|
|
{option.sku}
|
|
</button>
|
|
))}
|
|
{componentOptions.filter((option) => {
|
|
const query = (componentSearchTerms[index] ?? "").trim().toLowerCase();
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
|
|
return option.sku.toLowerCase().includes(query);
|
|
}).length === 0 ? (
|
|
<div className="px-2 py-2 text-sm text-muted">No matching components found.</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
value={line.quantity}
|
|
onChange={(event) => updateBomLine(index, { ...line, quantity: 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">UOM</span>
|
|
<select
|
|
value={line.unitOfMeasure}
|
|
onChange={(event) => updateBomLine(index, { ...line, unitOfMeasure: event.target.value as InventoryBomLineInput["unitOfMeasure"] })}
|
|
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
|
|
>
|
|
{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) })}
|
|
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">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingRemoval({ kind: "bom-line", index })}
|
|
className="rounded-2xl border border-rose-400/40 px-2 py-2 text-sm font-semibold text-rose-700 dark:text-rose-300"
|
|
>
|
|
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 })}
|
|
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>
|
|
)}
|
|
<div className="mt-4 flex flex-col gap-2 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<span className="min-w-0 text-sm text-muted">{status}</span>
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{isSaving ? "Saving..." : mode === "create" ? "Create item" : "Save changes"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<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);
|
|
}}
|
|
/>
|
|
</form>
|
|
);
|
|
}
|
|
|