inventory2

This commit is contained in:
2026-03-14 21:31:40 -05:00
parent 472c36915c
commit 65269f172f
4 changed files with 103 additions and 15 deletions

View File

@@ -16,9 +16,16 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
function getComponentLabel(componentItemId: string) {
const match = componentOptions.find((option) => option.id === componentItemId);
return match ? `${match.sku} - ${match.name}` : "";
}
useEffect(() => {
if (!token) {
return;
@@ -26,9 +33,22 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api
.getInventoryItemOptions(token)
.then((options) => setComponentOptions(options.filter((option) => option.id !== itemId)))
.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} - ${match.name}` : "";
})
);
})
.catch(() => setComponentOptions([]));
}, [itemId, token]);
}, [form.bomLines, itemId, token]);
useEffect(() => {
if (mode !== "edit" || !token || !itemId) {
@@ -57,6 +77,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
position: line.position,
})),
});
setComponentSearchTerms(item.bomLines.map((line) => `${line.componentSku} - ${line.componentName}`));
setStatus("Inventory item loaded.");
})
.catch((error: unknown) => {
@@ -76,6 +97,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
}));
}
function updateComponentSearchTerm(index: number, value: string) {
setComponentSearchTerms((current) => {
const nextTerms = [...current];
nextTerms[index] = value;
return nextTerms;
});
}
function addBomLine() {
setForm((current) => ({
...current,
@@ -87,6 +116,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
},
],
}));
setComponentSearchTerms((current) => [...current, ""]);
}
function removeBomLine(index: number) {
@@ -94,6 +124,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
...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));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
@@ -268,18 +300,69 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<div className="grid gap-4 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>
<select
value={line.componentItemId}
onChange={(event) => updateBomLine(index, { ...line, componentItemId: event.target.value })}
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
>
<option value="">Select component</option>
{componentOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.sku} - {option.name}
</option>
))}
</select>
<div className="relative">
<input
value={componentSearchTerms[index] ?? getComponentLabel(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, getComponentLabel(form.bomLines[index].componentItemId));
}
}, 120);
}}
placeholder="Search by SKU or item name"
className="w-full rounded-2xl border border-line/70 bg-surface px-4 py-3 text-text outline-none transition focus:border-brand"
/>
{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} ${option.name}`.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} - ${option.name}`);
setActiveComponentPicker(null);
}}
className="block w-full border-b border-line/50 px-4 py-3 text-left text-sm text-text transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold">{option.sku}</div>
<div className="mt-1 text-xs text-muted">{option.name}</div>
</button>
))}
{componentOptions.filter((option) => {
const query = (componentSearchTerms[index] ?? "").trim().toLowerCase();
if (!query) {
return true;
}
return `${option.sku} ${option.name}`.toLowerCase().includes(query);
}).length === 0 ? (
<div className="px-4 py-3 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>