From 8cbaa55b4879ca059b6b1e1e68a1dd4c8e088a2f Mon Sep 17 00:00:00 2001 From: David Beccue Date: Mon, 18 May 2026 20:49:53 +0500 Subject: [PATCH] Soft-delete parts and hide SKU/location/description from the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a part used to be impossible. Hard delete would cascade stock_movements (FK ON DELETE CASCADE) and orphan invoice_lines, losing the audit trail. Instead, the part detail page now has a Delete button that flips active=0; listParts and categoriesWithParts filter on active, but historical joins (recentMovements, linesFor, topSellingParts) stay unfiltered so old movements and invoices still render the part name. The existing active checkbox on the detail page doubles as a reactivate switch. SKU, location, and description fields are removed from every UI surface (forms, /parts table, dashboard, movement/invoice pickers, invoice line labels, top-sellers report). None were load-bearing — barcode + name + category already cover lookup. The SKU column is kept in the DB (NOT NULL UNIQUE) and auto-stamped server-side as `SKU-{id}` after insert, so the change is reversible without a migration. updatePart no longer writes SKU, freezing it after creation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/i18n/en.json | 7 ++-- src/lib/i18n/tg.json | 7 ++-- src/lib/server/parts.js | 43 ++++++++++++++++------ src/routes/+page.svelte | 8 ++--- src/routes/admin/reports/+page.svelte | 4 +-- src/routes/invoices/[id]/+page.svelte | 2 +- src/routes/invoices/new/+page.svelte | 5 ++- src/routes/movements/new/+page.svelte | 3 +- src/routes/parts/+page.server.js | 2 +- src/routes/parts/+page.svelte | 6 ++-- src/routes/parts/[id]/+page.server.js | 12 ++++--- src/routes/parts/[id]/+page.svelte | 51 ++++++++++++--------------- src/routes/parts/new/+page.server.js | 7 +--- src/routes/parts/new/+page.svelte | 26 ++------------ 14 files changed, 82 insertions(+), 101 deletions(-) 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 @@
-
- - +
-
- - -
- -
- - -
+
+ +
+ +