inventory2
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user