diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index ce614c8..e20f7aa 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -42,7 +42,7 @@ }, "dashboard": { "title": "Dashboard", - "total_skus": "Total SKUs", + "total_skus": "Total parts", "low_stock": "At or below reorder level", "inventory_value": "Inventory value (at cost)", "low_stock_list": "Low stock", @@ -68,11 +68,12 @@ "location": "Location", "barcode": "Barcode", "active": "Active", - "search_placeholder": "Search by SKU, name, or barcode…", + "search_placeholder": "Search by name or barcode…", "no_results": "No parts match your search.", "all": "All", "recent_movements": "Recent movements", "initial_quantity": "Initial quantity", + "delete_confirm": "Deactivate part \"{name}\"? It will be hidden from lists, but movements and counts will be kept.", "errors": { "sku_required": "SKU is required.", "name_required": "At least one name (English or Tajik) is required.", @@ -142,7 +143,7 @@ "this_month": "This month", "all_time": "All time", "invoices": "invoices", - "active_skus": "Active SKUs", + "active_skus": "Active parts", "units_on_hand": "Units on hand", "cost_value": "Value (at cost)", "sale_value": "Value (at sale)", diff --git a/src/lib/i18n/tg.json b/src/lib/i18n/tg.json index 8539e29..e1aa969 100644 --- a/src/lib/i18n/tg.json +++ b/src/lib/i18n/tg.json @@ -42,7 +42,7 @@ }, "dashboard": { "title": "Лавҳаи асосӣ", - "total_skus": "Ҳамаи SKU-ҳо", + "total_skus": "Ҳамаи қисмҳо", "low_stock": "Дар сатҳи фармоиш ё камтар", "inventory_value": "Арзиши захира (бо нархи харид)", "low_stock_list": "Захираи кам", @@ -68,11 +68,12 @@ "location": "Ҷой", "barcode": "Штрих-код", "active": "Фаъол", - "search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…", + "search_placeholder": "Ҷустуҷӯ аз рӯи ном ё штрих-код…", "no_results": "Ҳеҷ қисм мувофиқат намекунад.", "all": "Ҳама", "recent_movements": "Ҳаракатҳои охирин", "initial_quantity": "Шумораи аввала", + "delete_confirm": "Қисми «{name}»-ро ғайрифаъол мекунед? Дар рӯйхатҳо нишон дода намешавад, аммо ҳаракатҳо ва ҳисобҳо боқӣ мемонанд.", "errors": { "sku_required": "SKU зарур аст.", "name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.", @@ -142,7 +143,7 @@ "this_month": "Ин моҳ", "all_time": "Тамоми давра", "invoices": "фактура", - "active_skus": "SKU-ҳои фаъол", + "active_skus": "Қисмҳои фаъол", "units_on_hand": "Дар анбор", "cost_value": "Арзиш (бо нархи харид)", "sale_value": "Арзиш (бо нархи фурӯш)", diff --git a/src/lib/server/parts.js b/src/lib/server/parts.js index a861e19..8bc91bb 100644 --- a/src/lib/server/parts.js +++ b/src/lib/server/parts.js @@ -1,20 +1,21 @@ +import { randomUUID } from 'node:crypto'; import { getDb } from './db.js'; // Columns the user can sort the parts list by. Anything else is ignored. const SORTABLE = new Set([ - 'sku', 'name_en', 'name_tg', 'quantity_on_hand', + 'name_en', 'name_tg', 'quantity_on_hand', 'sale_price', 'cost_price', 'reorder_level', 'updated_at' ]); -export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = [] } = {}) { +export function listParts({ q = '', sort = 'name_en', dir = 'asc', categoryIds = [] } = {}) { const db = getDb(); - const col = SORTABLE.has(sort) ? sort : 'sku'; + const col = SORTABLE.has(sort) ? sort : 'name_en'; const order = dir === 'desc' ? 'DESC' : 'ASC'; - const where = []; + const where = ['p.active = 1']; const params = {}; if (q && q.trim()) { - 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.name_en LIKE @q OR p.name_tg LIKE @q OR p.barcode LIKE @q)`); params.q = `%${q.trim()}%`; } if (categoryIds && categoryIds.length) { @@ -22,7 +23,7 @@ export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = [] 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 ${where.join(' AND ')}`; const sql = ` SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg @@ -59,6 +60,7 @@ export function categoriesWithParts() { return getDb().prepare(` SELECT c.* FROM categories c JOIN parts p ON p.category_id = c.id + WHERE p.active = 1 GROUP BY c.id ORDER BY c.sort_order, c.name_en `).all(); @@ -66,7 +68,7 @@ export function categoriesWithParts() { export function createPart(input) { const db = getDb(); - const stmt = db.prepare(` + const insertStmt = db.prepare(` INSERT INTO parts (sku, name_en, name_tg, description_en, description_tg, category_id, unit, cost_price, sale_price, @@ -76,15 +78,28 @@ export function createPart(input) { @category_id, @unit, @cost_price, @sale_price, @quantity_on_hand, @reorder_level, @location, @barcode, @active) `); - const result = stmt.run(normalizePart(input)); - return result.lastInsertRowid; + const stampStmt = db.prepare(`UPDATE parts SET sku = 'SKU-' || id WHERE id = ?`); + + // SKU is hidden from the UI; the user never types one. The column is still + // NOT NULL UNIQUE, so insert with a uuid placeholder and rewrite to SKU-{id} + // once we know the row id. + const tx = db.transaction((data) => { + const userSku = (data.sku || '').trim(); + const sku = userSku || `__pending__${randomUUID()}`; + const result = insertStmt.run({ ...data, sku }); + const id = result.lastInsertRowid; + if (!userSku) stampStmt.run(id); + return id; + }); + return tx(normalizePart(input)); } export function updatePart(id, input) { const db = getDb(); + // SKU is intentionally NOT updated here — it's hidden from the UI and frozen + // after creation (auto-stamped as `SKU-{id}` in createPart). const stmt = db.prepare(` UPDATE parts SET - sku = @sku, name_en = @name_en, name_tg = @name_tg, description_en = @description_en, @@ -132,11 +147,17 @@ function toDirams(value) { return Math.round(num * 100); } +export function deactivatePart(id) { + getDb() + .prepare(`UPDATE parts SET active = 0, updated_at = datetime('now') WHERE id = ?`) + .run(Number(id)); +} + export function lowStockParts(limit = 10) { return getDb().prepare(` SELECT * FROM parts WHERE active = 1 AND quantity_on_hand <= reorder_level - ORDER BY (quantity_on_hand - reorder_level) ASC, sku ASC + ORDER BY (quantity_on_hand - reorder_level) ASC, name_en ASC LIMIT ? `).all(limit); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 89ba308..31c624b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -15,7 +15,6 @@ - @@ -24,8 +23,7 @@ {#each lowStock as p} - - + @@ -43,7 +41,6 @@ - @@ -53,8 +50,7 @@ - - + {/each} diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte index 1d81da7..9e3483a 100644 --- a/src/routes/admin/reports/+page.svelte +++ b/src/routes/admin/reports/+page.svelte @@ -80,7 +80,6 @@
{$t('parts.sku')} {$t('parts.name')} {$t('parts.quantity_on_hand')} {$t('parts.reorder_level')}
{p.sku}{localized(p, 'name', lang)}{localized(p, 'name', lang)} {p.quantity_on_hand} {p.reorder_level}
{$t('movements.created_at')} {$t('movements.type')}{$t('parts.sku')} {$t('parts.name')} {$t('movements.quantity')}
{m.created_at} {$t('movements.type_' + m.movement_type)}{m.sku}{localized(m, 'name', lang)}{localized(m, 'name', lang)} {m.quantity}
- @@ -91,8 +90,7 @@ {#each topParts as p} - - +
{$t('parts.sku')} {$t('parts.name')} {$t('reports.units_sold')} {$t('reports.sale')}
{p.sku}{localized(p, 'name', lang)}{localized(p, 'name', lang)} {p.units_sold} {formatMoney(p.sale_dirams, lang)} diff --git a/src/routes/invoices/[id]/+page.svelte b/src/routes/invoices/[id]/+page.svelte index 13ac0ea..31958d3 100644 --- a/src/routes/invoices/[id]/+page.svelte +++ b/src/routes/invoices/[id]/+page.svelte @@ -7,7 +7,7 @@ function lineLabel(line) { if (line.affects_inventory === 0) return line.label; - return `${line.part_sku} — ${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`; + return localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang); } diff --git a/src/routes/invoices/new/+page.svelte b/src/routes/invoices/new/+page.svelte index 2c57c65..5dd52f0 100644 --- a/src/routes/invoices/new/+page.svelte +++ b/src/routes/invoices/new/+page.svelte @@ -19,7 +19,6 @@ const q = partSearch.trim().toLowerCase(); if (!q) return parts; return parts.filter((p) => - (p.sku || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) || (p.barcode || '').toLowerCase().includes(q) @@ -59,7 +58,7 @@ function lineLabel(line) { if (line.affects_inventory === 0) return line.label; - return `${line.part_sku} — ${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`; + return localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang); } function confirmCancel(event) { @@ -88,7 +87,7 @@ {#each visibleParts as p} {/each} diff --git a/src/routes/movements/new/+page.svelte b/src/routes/movements/new/+page.svelte index ed157b4..0f1cab1 100644 --- a/src/routes/movements/new/+page.svelte +++ b/src/routes/movements/new/+page.svelte @@ -21,7 +21,6 @@ const q = partSearch.trim().toLowerCase(); if (!q) return parts; return parts.filter((p) => - (p.sku || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) || (p.barcode || '').toLowerCase().includes(q) @@ -105,7 +104,7 @@ {#each visibleParts as p} {/each} diff --git a/src/routes/parts/+page.server.js b/src/routes/parts/+page.server.js index 33babda..0e05928 100644 --- a/src/routes/parts/+page.server.js +++ b/src/routes/parts/+page.server.js @@ -2,7 +2,7 @@ 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 sort = url.searchParams.get('sort') ?? 'name_en'; const dir = url.searchParams.get('dir') ?? 'asc'; const cat = url.searchParams.get('category') ?? ''; const categoryIds = cat diff --git a/src/routes/parts/+page.svelte b/src/routes/parts/+page.svelte index 07cfbc3..f1f1a5e 100644 --- a/src/routes/parts/+page.svelte +++ b/src/routes/parts/+page.svelte @@ -15,7 +15,7 @@ const params = new URLSearchParams(); if (qNext) params.set('q', qNext); if (catsNext.length) params.set('category', catsNext.join(',')); - if (sortNext && sortNext !== 'sku') params.set('sort', sortNext); + if (sortNext && sortNext !== 'name_en') 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 }); @@ -99,7 +99,6 @@ - @@ -110,9 +109,8 @@ {#each parts as p} -
{$t('parts.category')}
{p.sku} - {localized(p, 'name', lang)} + {localized(p, 'name', lang)} {#if !hasTranslation(p, 'name', lang)} {$t('common.missing_translation')} {/if} diff --git a/src/routes/parts/[id]/+page.server.js b/src/routes/parts/[id]/+page.server.js index 904aa46..c2274ed 100644 --- a/src/routes/parts/[id]/+page.server.js +++ b/src/routes/parts/[id]/+page.server.js @@ -1,5 +1,5 @@ import { error, fail, redirect } from '@sveltejs/kit'; -import { getPart, getPartBySku, listCategories, updatePart } from '$lib/server/parts.js'; +import { deactivatePart, getPart, listCategories, updatePart } from '$lib/server/parts.js'; import { recentMovementsForPart } from '$lib/server/movements.js'; export function load({ params }) { @@ -14,21 +14,23 @@ export function load({ params }) { } export const actions = { - default: async ({ request, params }) => { + update: async ({ request, params }) => { const id = Number(params.id); const form = await request.formData(); const data = Object.fromEntries(form); const errors = {}; - if (!data.sku || !data.sku.trim()) errors.sku = 'parts.errors.sku_required'; if ((!data.name_en || !data.name_en.trim()) && (!data.name_tg || !data.name_tg.trim())) { errors.name = 'parts.errors.name_required'; } - const existing = getPartBySku(data.sku.trim()); - if (existing && existing.id !== id) errors.sku = 'parts.errors.sku_taken'; if (Object.keys(errors).length) return fail(400, { errors, values: data }); updatePart(id, data); throw redirect(303, `/parts/${id}`); + }, + + delete: async ({ params }) => { + deactivatePart(Number(params.id)); + throw redirect(303, '/parts'); } }; diff --git a/src/routes/parts/[id]/+page.svelte b/src/routes/parts/[id]/+page.svelte index 60c1c8c..1ec380e 100644 --- a/src/routes/parts/[id]/+page.svelte +++ b/src/routes/parts/[id]/+page.svelte @@ -1,4 +1,5 @@
-

{$t('parts.edit')}: {part.sku}

+

{$t('parts.edit')}: {localized(part, 'name', lang) || part.id}

← {$t('common.back')}
@@ -27,13 +34,7 @@
-
- - +
-
- - -
- -
- - -
+
+ +
+ +