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

@ -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;