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.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user