From 9d756e29406df93bde05f46cf411663ae7b4a2f4 Mon Sep 17 00:00:00 2001 From: David Beccue Date: Sat, 16 May 2026 12:08:47 +0500 Subject: [PATCH] Add category filters and live search to parts page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- prompts.txt | 8 +++ src/lib/i18n/en.json | 1 + src/lib/i18n/tg.json | 1 + src/lib/server/parts.js | 18 ++++- src/routes/parts/+page.server.js | 13 +++- src/routes/parts/+page.svelte | 111 +++++++++++++++++++++++++------ 6 files changed, 127 insertions(+), 25 deletions(-) diff --git a/prompts.txt b/prompts.txt index 8e802e0..c41c966 100644 --- a/prompts.txt +++ b/prompts.txt @@ -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? + + diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index fe956f7..1be4c1e 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -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": { diff --git a/src/lib/i18n/tg.json b/src/lib/i18n/tg.json index 2439a4e..6952934 100644 --- a/src/lib/i18n/tg.json +++ b/src/lib/i18n/tg.json @@ -68,6 +68,7 @@ "active": "Фаъол", "search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…", "no_results": "Ҳеҷ қисм мувофиқат намекунад.", + "all": "Ҳама", "recent_movements": "Ҳаракатҳои охирин", "initial_quantity": "Шумораи аввала", "errors": { diff --git a/src/lib/server/parts.js b/src/lib/server/parts.js index f7797e4..22827ae 100644 --- a/src/lib/server/parts.js +++ b/src/lib/server/parts.js @@ -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(` diff --git a/src/routes/parts/+page.server.js b/src/routes/parts/+page.server.js index 7217237..33babda 100644 --- a/src/routes/parts/+page.server.js +++ b/src/routes/parts/+page.server.js @@ -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, + }; } diff --git a/src/routes/parts/+page.svelte b/src/routes/parts/+page.svelte index 0c93d80..07cfbc3 100644 --- a/src/routes/parts/+page.svelte +++ b/src/routes/parts/+page.svelte @@ -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' ? '▲' : '▼') : '');
@@ -38,18 +60,38 @@ + {$t('nav.new_part')}
- + +{#if categories.length > 0} +
+ + {#each categories as c} + + {/each} +
+{/if} {#if parts.length === 0}

{$t('parts.no_results')}

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