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

@@ -18,6 +18,7 @@ This repository implements the platform foundation milestone:
3. Add Prisma models and migrations for all persisted schema changes. 3. Add Prisma models and migrations for all persisted schema changes.
4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite. 4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite.
5. Reuse shared DTOs and permission keys from the `shared` package. 5. Reuse shared DTOs and permission keys from the `shared` package.
6. Any UI that looks up items by SKU or item name must use a searchable picker/autocomplete, not a long dropdown.
## Operational notes ## Operational notes
@@ -27,6 +28,7 @@ This repository implements the platform foundation milestone:
- Prefer Node 22 locally when running Prisma migration commands to match the Docker runtime. - Prefer Node 22 locally when running Prisma migration commands to match the Docker runtime.
- Branding defaults live in the frontend theme token layer and are overridden by the persisted company profile. - Branding defaults live in the frontend theme token layer and are overridden by the persisted company profile.
- Back up the whole `/app/data` volume to capture both the database and attachments. - Back up the whole `/app/data` volume to capture both the database and attachments.
- Treat searchable SKU lookup as a standing UX requirement for inventory, BOM, sales, purchasing, and manufacturing flows.
## Next roadmap candidates ## Next roadmap candidates

View File

@@ -84,6 +84,7 @@ The current inventory foundation supports:
- protected item master list, detail, create, and edit flows - protected item master list, detail, create, and edit flows
- SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, and notes fields - SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, and notes fields
- BOM header and BOM line editing directly on the item form - BOM header and BOM line editing directly on the item form
- searchable component lookup for BOM lines, designed for large item catalogs
- BOM detail display with component SKU, name, quantity, unit, notes, and position - BOM detail display with component SKU, name, quantity, unit, notes, and position
- protected warehouse list, detail, create, and edit flows - protected warehouse list, detail, create, and edit flows
- nested stock-location management inside each warehouse record - nested stock-location management inside each warehouse record
@@ -92,6 +93,8 @@ The current inventory foundation supports:
This module introduces `inventory.read` and `inventory.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role. This module introduces `inventory.read` and `inventory.write` permissions. After updating the code, restart the server against the migrated database so bootstrap can upsert the new permissions onto the default administrator role.
Moving forward, any UI that requires searching for an item by SKU or item name should use a searchable picker/autocomplete rather than a static dropdown.
## Branding ## Branding
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand. Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.

View File

@@ -15,6 +15,7 @@
- Keep reusable UI primitives in `src/components`. - Keep reusable UI primitives in `src/components`.
- Theme state and brand tokens belong in `src/theme`. - Theme state and brand tokens belong in `src/theme`.
- PDF screen components must remain separate from API-rendered document templates. - PDF screen components must remain separate from API-rendered document templates.
- Any item/SKU lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for inventory-scale datasets.
## Backend rules ## Backend rules
@@ -36,4 +37,3 @@
3. Add permission keys in `shared/src/auth`. 3. Add permission keys in `shared/src/auth`.
4. Add frontend route/module under `client/src/modules/<domain>`. 4. Add frontend route/module under `client/src/modules/<domain>`.
5. Register navigation and route guards through the app shell without refactoring existing modules. 5. Register navigation and route guards through the app shell without refactoring existing modules.

View File

@@ -16,9 +16,16 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const { itemId } = useParams(); const { itemId } = useParams();
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput); const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]); 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 [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
function getComponentLabel(componentItemId: string) {
const match = componentOptions.find((option) => option.id === componentItemId);
return match ? `${match.sku} - ${match.name}` : "";
}
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
return; return;
@@ -26,9 +33,22 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api api
.getInventoryItemOptions(token) .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([])); .catch(() => setComponentOptions([]));
}, [itemId, token]); }, [form.bomLines, itemId, token]);
useEffect(() => { useEffect(() => {
if (mode !== "edit" || !token || !itemId) { if (mode !== "edit" || !token || !itemId) {
@@ -57,6 +77,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
position: line.position, position: line.position,
})), })),
}); });
setComponentSearchTerms(item.bomLines.map((line) => `${line.componentSku} - ${line.componentName}`));
setStatus("Inventory item loaded."); setStatus("Inventory item loaded.");
}) })
.catch((error: unknown) => { .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() { function addBomLine() {
setForm((current) => ({ setForm((current) => ({
...current, ...current,
@@ -87,6 +116,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
}, },
], ],
})); }));
setComponentSearchTerms((current) => [...current, ""]);
} }
function removeBomLine(index: number) { function removeBomLine(index: number) {
@@ -94,6 +124,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
...current, ...current,
bomLines: current.bomLines.filter((_line, lineIndex) => lineIndex !== index), 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>) { 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]"> <div className="grid gap-4 xl:grid-cols-[1.4fr_0.7fr_0.7fr_0.7fr_auto]">
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Component</span>
<select <div className="relative">
value={line.componentItemId} <input
onChange={(event) => updateBomLine(index, { ...line, componentItemId: event.target.value })} 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" 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"
> >
<option value="">Select component</option> <div className="font-semibold">{option.sku}</div>
{componentOptions.map((option) => ( <div className="mt-1 text-xs text-muted">{option.name}</div>
<option key={option.id} value={option.id}> </button>
{option.sku} - {option.name}
</option>
))} ))}
</select> {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>
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span> <span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>