auto suggest fix

This commit is contained in:
2026-03-14 23:07:43 -05:00
parent 8bf69c67e0
commit d44d97e47b
4 changed files with 78 additions and 16 deletions

View File

@@ -20,7 +20,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.
6. Any non-filter UI that looks up records or items must use a searchable picker/autocomplete, not a long static dropdown.
7. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
## Operational notes
@@ -31,7 +31,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.
- Treat searchable lookup as a standing UX requirement for inventory, BOM, sales, purchasing, manufacturing, customer, vendor, and other operational record-picking flows. Filter-only controls can still use dropdowns.
## Next roadmap candidates

View File

@@ -94,7 +94,7 @@ 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.
Moving forward, any UI that requires searching for records or items should use a searchable picker/autocomplete rather than a static dropdown. Filter controls can remain dropdowns, but non-filter lookup fields such as SKU pickers, customer selectors, vendor selectors, and similar operational search inputs should not be implemented as long static selects.
## UI Density

View File

@@ -15,7 +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.
- Any non-filter lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for operational datasets such as items, customers, vendors, or document-linked records.
- Preserve the current dense operations UI style on active module pages: compact controls, tighter card padding, and shorter empty states unless a screen has a clear reason to be more spacious.
## Backend rules

View File

@@ -17,6 +17,8 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
const [form, setForm] = useState<SalesDocumentInput>(emptySalesDocumentInput);
const [status, setStatus] = useState(mode === "create" ? `Create a new ${config.singularLabel.toLowerCase()}.` : `Loading ${config.singularLabel.toLowerCase()}...`);
const [customers, setCustomers] = useState<SalesCustomerOptionDto[]>([]);
const [customerSearchTerm, setCustomerSearchTerm] = useState("");
const [customerPickerOpen, setCustomerPickerOpen] = useState(false);
const [itemOptions, setItemOptions] = useState<InventoryItemOptionDto[]>([]);
const [lineSearchTerms, setLineSearchTerms] = useState<string[]>([]);
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
@@ -54,6 +56,7 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
position: line.position,
})),
});
setCustomerSearchTerm(document.customerName);
setLineSearchTerms(document.lines.map((line: SalesDocumentDetailDto["lines"][number]) => line.itemSku));
setStatus(`${config.singularLabel} loaded.`);
})
@@ -67,6 +70,10 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
setForm((current: SalesDocumentInput) => ({ ...current, [key]: value }));
}
function getSelectedCustomerName(customerId: string) {
return customers.find((customer) => customer.id === customerId)?.name ?? "";
}
function updateLine(index: number, nextLine: SalesLineInput) {
setForm((current: SalesDocumentInput) => ({
...current,
@@ -152,18 +159,73 @@ export function SalesFormPage({ entity, mode }: { entity: SalesDocumentEntity; m
<div className="grid gap-3 xl:grid-cols-4">
<label className="block xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Customer</span>
<select
value={form.customerId}
onChange={(event) => updateField("customerId", event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
<option value="">Select customer</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<div className="relative">
<input
value={customerSearchTerm}
onChange={(event) => {
setCustomerSearchTerm(event.target.value);
updateField("customerId", "");
setCustomerPickerOpen(true);
}}
onFocus={() => setCustomerPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setCustomerPickerOpen(false);
if (form.customerId) {
setCustomerSearchTerm(getSelectedCustomerName(form.customerId));
}
}, 120);
}}
placeholder="Search customer"
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
{customerPickerOpen ? (
<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">
{customers
.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return (
customer.name.toLowerCase().includes(query) ||
customer.email.toLowerCase().includes(query)
);
})
.slice(0, 12)
.map((customer) => (
<button
key={customer.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("customerId", customer.id);
setCustomerSearchTerm(customer.name);
setCustomerPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-4 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{customer.name}</div>
<div className="mt-1 text-xs text-muted">{customer.email}</div>
</button>
))}
{customers.filter((customer) => {
const query = customerSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return customer.name.toLowerCase().includes(query) || customer.email.toLowerCase().includes(query);
}).length === 0 ? (
<div className="px-2 py-2 text-sm text-muted">No matching customers found.</div>
) : null}
</div>
) : null}
</div>
<div className="mt-2 min-h-5 text-xs text-muted">
{form.customerId ? getSelectedCustomerName(form.customerId) : "No customer selected"}
</div>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Status</span>