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.
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.
6. Any UI that looks up items by SKU or item name must use a searchable picker/autocomplete, not a long dropdown.
## 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.
- 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.
- Treat searchable SKU lookup as a standing UX requirement for inventory, BOM, sales, purchasing, and manufacturing flows.
## Next roadmap candidates

View File

@@ -84,6 +84,7 @@ The current inventory foundation supports:
- protected item master list, detail, create, and edit flows
- 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
- searchable component lookup for BOM lines, designed for large item catalogs
- BOM detail display with component SKU, name, quantity, unit, notes, and position
- protected warehouse list, detail, create, and edit flows
- 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.
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
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`.
- Theme state and brand tokens belong in `src/theme`.
- 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
@@ -36,4 +37,3 @@
3. Add permission keys in `shared/src/auth`.
4. Add frontend route/module under `client/src/modules/<domain>`.
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 [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>