Add category filters and live search to parts page

- Filter chips for in-use categories with OR semantics; "All" chip shown
  when nothing is selected.
- Search input filters as the user types (150 ms debounce, replaceState
  so back-button stays useful).
- Fix sort indicators: the `arrow()` helper read `sort`/`dir` from a
  plain function, which Svelte's static dep tracking doesn't trace —
  the ▲/▼ never updated on client-side sorts. Made it a reactive `$:`
  declaration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Beccue
2026-05-16 12:08:47 +05:00
parent b22630a870
commit 9d756e2940
6 changed files with 127 additions and 25 deletions

View File

@ -165,3 +165,11 @@ Short: what it is, prerequisites (Docker), quickstart
2. Print the resulting file tree. 2. Print the resulting file tree.
3. Print the exact command sequence to bring it up from a fresh clone. 3. Print the exact command sequence to bring it up from a fresh clone.
4. Call out anything you guessed at that I should review before we move on. 4. Call out anything you guessed at that I should review before we move on.
When I select Record Movement in the Parts page, can't we prepopulate the movement since we know the part?
ANd shouldn't some of the fields be defaulted to our best guess based on what we know about the part?

View File

@ -68,6 +68,7 @@
"active": "Active", "active": "Active",
"search_placeholder": "Search by SKU, name, or barcode…", "search_placeholder": "Search by SKU, name, or barcode…",
"no_results": "No parts match your search.", "no_results": "No parts match your search.",
"all": "All",
"recent_movements": "Recent movements", "recent_movements": "Recent movements",
"initial_quantity": "Initial quantity", "initial_quantity": "Initial quantity",
"errors": { "errors": {

View File

@ -68,6 +68,7 @@
"active": "Фаъол", "active": "Фаъол",
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…", "search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…",
"no_results": "Ҳеҷ қисм мувофиқат намекунад.", "no_results": "Ҳеҷ қисм мувофиқат намекунад.",
"all": "Ҳама",
"recent_movements": "Ҳаракатҳои охирин", "recent_movements": "Ҳаракатҳои охирин",
"initial_quantity": "Шумораи аввала", "initial_quantity": "Шумораи аввала",
"errors": { "errors": {

View File

@ -6,7 +6,7 @@ const SORTABLE = new Set([
'sale_price', 'cost_price', 'reorder_level', 'updated_at' 'sale_price', 'cost_price', 'reorder_level', 'updated_at'
]); ]);
export function listParts({ q = '', sort = 'sku', dir = 'asc' } = {}) { export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = [] } = {}) {
const db = getDb(); const db = getDb();
const col = SORTABLE.has(sort) ? sort : 'sku'; const col = SORTABLE.has(sort) ? sort : 'sku';
const order = dir === 'desc' ? 'DESC' : 'ASC'; const order = dir === 'desc' ? 'DESC' : 'ASC';
@ -17,6 +17,11 @@ export function listParts({ q = '', sort = 'sku', dir = 'asc' } = {}) {
where.push(`(p.sku LIKE @q OR p.name_en LIKE @q OR p.name_tg LIKE @q OR p.barcode LIKE @q)`); where.push(`(p.sku LIKE @q OR p.name_en LIKE @q OR p.name_tg LIKE @q OR p.barcode LIKE @q)`);
params.q = `%${q.trim()}%`; params.q = `%${q.trim()}%`;
} }
if (categoryIds && categoryIds.length) {
const placeholders = categoryIds.map((_, i) => `@cat${i}`).join(',');
where.push(`p.category_id IN (${placeholders})`);
categoryIds.forEach((id, i) => { params[`cat${i}`] = id; });
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const sql = ` const sql = `
@ -48,6 +53,17 @@ export function listCategories() {
.all(); .all();
} }
// Categories that have at least one part — used to build the filter chips on
// the parts list so we don't offer empty options.
export function categoriesWithParts() {
return getDb().prepare(`
SELECT c.* FROM categories c
JOIN parts p ON p.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name_en
`).all();
}
export function createPart(input) { export function createPart(input) {
const db = getDb(); const db = getDb();
const stmt = db.prepare(` const stmt = db.prepare(`

View File

@ -1,8 +1,17 @@
import { listParts } from '$lib/server/parts.js'; import { listParts, categoriesWithParts } from '$lib/server/parts.js';
export function load({ url }) { export function load({ url }) {
const q = url.searchParams.get('q') ?? ''; const q = url.searchParams.get('q') ?? '';
const sort = url.searchParams.get('sort') ?? 'sku'; const sort = url.searchParams.get('sort') ?? 'sku';
const dir = url.searchParams.get('dir') ?? 'asc'; const dir = url.searchParams.get('dir') ?? 'asc';
return { parts: listParts({ q, sort, dir }), q, sort, dir }; const cat = url.searchParams.get('category') ?? '';
const categoryIds = cat
.split(',')
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n > 0);
return {
parts: listParts({ q, sort, dir, categoryIds }),
categories: categoriesWithParts(),
q, sort, dir, categoryIds,
};
} }

View File

@ -4,33 +4,55 @@
export let data; export let data;
$: lang = $locale; $: lang = $locale;
$: ({ parts, q, sort, dir } = data); $: ({ parts, categories, q, sort, dir, categoryIds } = data);
let search = data.q; let search = data.q;
let searchTimer = null;
function applySearch(e) { $: selectedSet = new Set(categoryIds);
e?.preventDefault?.();
function navigate({ qNext = search, sortNext = sort, dirNext = dir, catsNext = categoryIds } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set('q', search); if (qNext) params.set('q', qNext);
if (sort && sort !== 'sku') params.set('sort', sort); if (catsNext.length) params.set('category', catsNext.join(','));
if (dir && dir !== 'asc') params.set('dir', dir); if (sortNext && sortNext !== 'sku') params.set('sort', sortNext);
goto('/parts' + (params.toString() ? '?' + params.toString() : '')); if (dirNext && dirNext !== 'asc') params.set('dir', dirNext);
const target = '/parts' + (params.toString() ? '?' + params.toString() : '');
goto(target, { replaceState: true, keepFocus: true, noScroll: true });
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => navigate({ qNext: search }), 150);
}
function clearSearch() {
clearTimeout(searchTimer);
search = '';
navigate({ qNext: '' });
}
function toggleCategory(id) {
const next = selectedSet.has(id)
? categoryIds.filter((c) => c !== id)
: [...categoryIds, id];
navigate({ catsNext: next });
}
function clearCategories() {
navigate({ catsNext: [] });
} }
function sortBy(col) { function sortBy(col) {
let nextDir = 'asc'; let nextDir = 'asc';
if (sort === col && dir === 'asc') nextDir = 'desc'; if (sort === col && dir === 'asc') nextDir = 'desc';
const params = new URLSearchParams(); navigate({ sortNext: col, dirNext: nextDir });
if (search) params.set('q', search);
params.set('sort', col);
params.set('dir', nextDir);
goto('/parts?' + params.toString());
} }
function arrow(col) { // Reactive so the header indicators refresh when `sort` / `dir` change.
if (sort !== col) return ''; // A plain function declaration wouldn't — Svelte tracks reactive deps via
return dir === 'asc' ? '▲' : '▼'; // static analysis and doesn't look inside function bodies.
} $: arrow = (col) => (sort === col ? (dir === 'asc' ? '▲' : '▼') : '');
</script> </script>
<div class="page-head"> <div class="page-head">
@ -38,18 +60,38 @@
<a class="add-btn" href="/parts/new">+ {$t('nav.new_part')}</a> <a class="add-btn" href="/parts/new">+ {$t('nav.new_part')}</a>
</div> </div>
<form class="search" on:submit={applySearch}> <div class="search">
<input type="search" <input type="search"
bind:value={search} bind:value={search}
on:input={onSearchInput}
placeholder={$t('parts.search_placeholder')} /> placeholder={$t('parts.search_placeholder')} />
<button type="submit">{$t('common.search')}</button>
{#if search} {#if search}
<button type="button" class="secondary" <button type="button" class="secondary" on:click={clearSearch}>
on:click={() => { search = ''; applySearch(); }}>
{$t('common.clear')} {$t('common.clear')}
</button> </button>
{/if} {/if}
</form> </div>
{#if categories.length > 0}
<div class="filters" role="group" aria-label={$t('parts.category')}>
<button type="button"
class="chip"
class:active={categoryIds.length === 0}
aria-pressed={categoryIds.length === 0}
on:click={clearCategories}>
{$t('parts.all')}
</button>
{#each categories as c}
<button type="button"
class="chip"
class:active={selectedSet.has(c.id)}
aria-pressed={selectedSet.has(c.id)}
on:click={() => toggleCategory(c.id)}>
{localized(c, 'name', lang)}
</button>
{/each}
</div>
{/if}
{#if parts.length === 0} {#if parts.length === 0}
<p class="muted card">{$t('parts.no_results')}</p> <p class="muted card">{$t('parts.no_results')}</p>
@ -104,9 +146,34 @@
.search { .search {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin: 0.5rem 0 1rem; margin: 0.5rem 0 0.75rem;
} }
.search input { flex: 1; } .search input { flex: 1; }
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0 0 1rem;
}
.chip {
background: #fff;
color: #1d2330;
border: 1px solid #c8cfdc;
padding: 0.3rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
transition: background 0.1s, border-color 0.1s, color 0.1s;
}
.chip:hover { background: #f0f2f6; }
.chip.active {
background: #006a4e;
color: #fff;
border-color: #006a4e;
}
.chip.active:hover { background: #00553e; border-color: #00553e; }
.th-btn { .th-btn {
background: transparent; background: transparent;
color: inherit; color: inherit;