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.
|
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?
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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(`
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user