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:
@ -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?
|
||||
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -68,6 +68,7 @@
|
||||
"active": "Фаъол",
|
||||
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…",
|
||||
"no_results": "Ҳеҷ қисм мувофиқат намекунад.",
|
||||
"all": "Ҳама",
|
||||
"recent_movements": "Ҳаракатҳои охирин",
|
||||
"initial_quantity": "Шумораи аввала",
|
||||
"errors": {
|
||||
|
||||
@ -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(`
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user