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.
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.
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",
"search_placeholder": "Search by SKU, name, or barcode…",
"no_results": "No parts match your search.",
"all": "All",
"recent_movements": "Recent movements",
"initial_quantity": "Initial quantity",
"errors": {

View File

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

View File

@ -6,7 +6,7 @@ const SORTABLE = new Set([
'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 col = SORTABLE.has(sort) ? sort : 'sku';
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)`);
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 sql = `
@ -48,6 +53,17 @@ export function listCategories() {
.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) {
const db = getDb();
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 }) {
const q = url.searchParams.get('q') ?? '';
const sort = url.searchParams.get('sort') ?? 'sku';
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;
$: lang = $locale;
$: ({ parts, q, sort, dir } = data);
$: ({ parts, categories, q, sort, dir, categoryIds } = data);
let search = data.q;
let searchTimer = null;
function applySearch(e) {
e?.preventDefault?.();
$: selectedSet = new Set(categoryIds);
function navigate({ qNext = search, sortNext = sort, dirNext = dir, catsNext = categoryIds } = {}) {
const params = new URLSearchParams();
if (search) params.set('q', search);
if (sort && sort !== 'sku') params.set('sort', sort);
if (dir && dir !== 'asc') params.set('dir', dir);
goto('/parts' + (params.toString() ? '?' + params.toString() : ''));
if (qNext) params.set('q', qNext);
if (catsNext.length) params.set('category', catsNext.join(','));
if (sortNext && sortNext !== 'sku') params.set('sort', sortNext);
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) {
let nextDir = 'asc';
if (sort === col && dir === 'asc') nextDir = 'desc';
const params = new URLSearchParams();
if (search) params.set('q', search);
params.set('sort', col);
params.set('dir', nextDir);
goto('/parts?' + params.toString());
navigate({ sortNext: col, dirNext: nextDir });
}
function arrow(col) {
if (sort !== col) return '';
return dir === 'asc' ? '▲' : '▼';
}
// Reactive so the header indicators refresh when `sort` / `dir` change.
// A plain function declaration wouldn't — Svelte tracks reactive deps via
// static analysis and doesn't look inside function bodies.
$: arrow = (col) => (sort === col ? (dir === 'asc' ? '▲' : '▼') : '');
</script>
<div class="page-head">
@ -38,18 +60,38 @@
<a class="add-btn" href="/parts/new">+ {$t('nav.new_part')}</a>
</div>
<form class="search" on:submit={applySearch}>
<div class="search">
<input type="search"
bind:value={search}
on:input={onSearchInput}
placeholder={$t('parts.search_placeholder')} />
<button type="submit">{$t('common.search')}</button>
{#if search}
<button type="button" class="secondary"
on:click={() => { search = ''; applySearch(); }}>
<button type="button" class="secondary" on:click={clearSearch}>
{$t('common.clear')}
</button>
{/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}
<p class="muted card">{$t('parts.no_results')}</p>
@ -104,9 +146,34 @@
.search {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0 1rem;
margin: 0.5rem 0 0.75rem;
}
.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 {
background: transparent;
color: inherit;