This commit is contained in:
2026-03-15 16:40:25 -05:00
parent 15116807ce
commit 59754c7657
33 changed files with 1620 additions and 49 deletions

View File

@@ -252,6 +252,10 @@ export function InventoryDetailPage() {
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<dd className="mt-2 text-sm text-text">

View File

@@ -1,3 +1,4 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
@@ -18,8 +19,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
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);
@@ -72,6 +76,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable,
isPurchasable: item.isPurchasable,
preferredVendorId: item.preferredVendorId,
defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice,
notes: item.notes,
@@ -93,6 +98,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
});
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.";
@@ -106,12 +112,21 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
}
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
}, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
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,
@@ -315,6 +330,76 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</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) : "Demand planning uses this vendor when creating buy recommendations."}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea

View File

@@ -41,6 +41,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
unitOfMeasure: "EA",
isSellable: true,
isPurchasable: true,
preferredVendorId: null,
defaultCost: null,
defaultPrice: null,
notes: "",