Soft-delete parts and hide SKU/location/description from the UI

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) <noreply@anthropic.com>
This commit is contained in:
David Beccue
2026-05-18 20:49:53 +05:00
parent 82bb456103
commit 8cbaa55b48
14 changed files with 82 additions and 101 deletions

View File

@ -42,7 +42,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"total_skus": "Total SKUs", "total_skus": "Total parts",
"low_stock": "At or below reorder level", "low_stock": "At or below reorder level",
"inventory_value": "Inventory value (at cost)", "inventory_value": "Inventory value (at cost)",
"low_stock_list": "Low stock", "low_stock_list": "Low stock",
@ -68,11 +68,12 @@
"location": "Location", "location": "Location",
"barcode": "Barcode", "barcode": "Barcode",
"active": "Active", "active": "Active",
"search_placeholder": "Search by SKU, name, or barcode…", "search_placeholder": "Search by name or barcode…",
"no_results": "No parts match your search.", "no_results": "No parts match your search.",
"all": "All", "all": "All",
"recent_movements": "Recent movements", "recent_movements": "Recent movements",
"initial_quantity": "Initial quantity", "initial_quantity": "Initial quantity",
"delete_confirm": "Deactivate part \"{name}\"? It will be hidden from lists, but movements and counts will be kept.",
"errors": { "errors": {
"sku_required": "SKU is required.", "sku_required": "SKU is required.",
"name_required": "At least one name (English or Tajik) is required.", "name_required": "At least one name (English or Tajik) is required.",
@ -142,7 +143,7 @@
"this_month": "This month", "this_month": "This month",
"all_time": "All time", "all_time": "All time",
"invoices": "invoices", "invoices": "invoices",
"active_skus": "Active SKUs", "active_skus": "Active parts",
"units_on_hand": "Units on hand", "units_on_hand": "Units on hand",
"cost_value": "Value (at cost)", "cost_value": "Value (at cost)",
"sale_value": "Value (at sale)", "sale_value": "Value (at sale)",

View File

@ -42,7 +42,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Лавҳаи асосӣ", "title": "Лавҳаи асосӣ",
"total_skus": "Ҳамаи SKU-ҳо", "total_skus": "Ҳамаи қисмҳо",
"low_stock": "Дар сатҳи фармоиш ё камтар", "low_stock": "Дар сатҳи фармоиш ё камтар",
"inventory_value": "Арзиши захира (бо нархи харид)", "inventory_value": "Арзиши захира (бо нархи харид)",
"low_stock_list": "Захираи кам", "low_stock_list": "Захираи кам",
@ -68,11 +68,12 @@
"location": "Ҷой", "location": "Ҷой",
"barcode": "Штрих-код", "barcode": "Штрих-код",
"active": "Фаъол", "active": "Фаъол",
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…", "search_placeholder": "Ҷустуҷӯ аз рӯи ном ё штрих-код…",
"no_results": "Ҳеҷ қисм мувофиқат намекунад.", "no_results": "Ҳеҷ қисм мувофиқат намекунад.",
"all": "Ҳама", "all": "Ҳама",
"recent_movements": "Ҳаракатҳои охирин", "recent_movements": "Ҳаракатҳои охирин",
"initial_quantity": "Шумораи аввала", "initial_quantity": "Шумораи аввала",
"delete_confirm": "Қисми «{name}»-ро ғайрифаъол мекунед? Дар рӯйхатҳо нишон дода намешавад, аммо ҳаракатҳо ва ҳисобҳо боқӣ мемонанд.",
"errors": { "errors": {
"sku_required": "SKU зарур аст.", "sku_required": "SKU зарур аст.",
"name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.", "name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.",
@ -142,7 +143,7 @@
"this_month": "Ин моҳ", "this_month": "Ин моҳ",
"all_time": "Тамоми давра", "all_time": "Тамоми давра",
"invoices": "фактура", "invoices": "фактура",
"active_skus": "SKU-ҳои фаъол", "active_skus": "Қисмҳои фаъол",
"units_on_hand": "Дар анбор", "units_on_hand": "Дар анбор",
"cost_value": "Арзиш (бо нархи харид)", "cost_value": "Арзиш (бо нархи харид)",
"sale_value": "Арзиш (бо нархи фурӯш)", "sale_value": "Арзиш (бо нархи фурӯш)",

View File

@ -1,20 +1,21 @@
import { randomUUID } from 'node:crypto';
import { getDb } from './db.js'; import { getDb } from './db.js';
// Columns the user can sort the parts list by. Anything else is ignored. // Columns the user can sort the parts list by. Anything else is ignored.
const SORTABLE = new Set([ 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' '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 db = getDb();
const col = SORTABLE.has(sort) ? sort : 'sku'; const col = SORTABLE.has(sort) ? sort : 'name_en';
const order = dir === 'desc' ? 'DESC' : 'ASC'; const order = dir === 'desc' ? 'DESC' : 'ASC';
const where = []; const where = ['p.active = 1'];
const params = {}; const params = {};
if (q && q.trim()) { 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()}%`; params.q = `%${q.trim()}%`;
} }
if (categoryIds && categoryIds.length) { if (categoryIds && categoryIds.length) {
@ -22,7 +23,7 @@ export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = []
where.push(`p.category_id IN (${placeholders})`); where.push(`p.category_id IN (${placeholders})`);
categoryIds.forEach((id, i) => { params[`cat${i}`] = id; }); categoryIds.forEach((id, i) => { params[`cat${i}`] = id; });
} }
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; const whereSql = `WHERE ${where.join(' AND ')}`;
const sql = ` const sql = `
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg 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(` return getDb().prepare(`
SELECT c.* FROM categories c SELECT c.* FROM categories c
JOIN parts p ON p.category_id = c.id JOIN parts p ON p.category_id = c.id
WHERE p.active = 1
GROUP BY c.id GROUP BY c.id
ORDER BY c.sort_order, c.name_en ORDER BY c.sort_order, c.name_en
`).all(); `).all();
@ -66,7 +68,7 @@ export function categoriesWithParts() {
export function createPart(input) { export function createPart(input) {
const db = getDb(); const db = getDb();
const stmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO parts INSERT INTO parts
(sku, name_en, name_tg, description_en, description_tg, (sku, name_en, name_tg, description_en, description_tg,
category_id, unit, cost_price, sale_price, category_id, unit, cost_price, sale_price,
@ -76,15 +78,28 @@ export function createPart(input) {
@category_id, @unit, @cost_price, @sale_price, @category_id, @unit, @cost_price, @sale_price,
@quantity_on_hand, @reorder_level, @location, @barcode, @active) @quantity_on_hand, @reorder_level, @location, @barcode, @active)
`); `);
const result = stmt.run(normalizePart(input)); const stampStmt = db.prepare(`UPDATE parts SET sku = 'SKU-' || id WHERE id = ?`);
return result.lastInsertRowid;
// 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) { export function updatePart(id, input) {
const db = getDb(); 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(` const stmt = db.prepare(`
UPDATE parts SET UPDATE parts SET
sku = @sku,
name_en = @name_en, name_en = @name_en,
name_tg = @name_tg, name_tg = @name_tg,
description_en = @description_en, description_en = @description_en,
@ -132,11 +147,17 @@ function toDirams(value) {
return Math.round(num * 100); 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) { export function lowStockParts(limit = 10) {
return getDb().prepare(` return getDb().prepare(`
SELECT * FROM parts SELECT * FROM parts
WHERE active = 1 AND quantity_on_hand <= reorder_level 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 ? LIMIT ?
`).all(limit); `).all(limit);
} }

View File

@ -15,7 +15,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('parts.quantity_on_hand')}</th> <th class="num">{$t('parts.quantity_on_hand')}</th>
<th class="num">{$t('parts.reorder_level')}</th> <th class="num">{$t('parts.reorder_level')}</th>
@ -24,8 +23,7 @@
<tbody> <tbody>
{#each lowStock as p} {#each lowStock as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td> <td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td class="num"><span class="pill low">{p.quantity_on_hand}</span></td> <td class="num"><span class="pill low">{p.quantity_on_hand}</span></td>
<td class="num">{p.reorder_level}</td> <td class="num">{p.reorder_level}</td>
</tr> </tr>
@ -43,7 +41,6 @@
<tr> <tr>
<th>{$t('movements.created_at')}</th> <th>{$t('movements.created_at')}</th>
<th>{$t('movements.type')}</th> <th>{$t('movements.type')}</th>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('movements.quantity')}</th> <th class="num">{$t('movements.quantity')}</th>
</tr> </tr>
@ -53,8 +50,7 @@
<tr> <tr>
<td>{m.created_at}</td> <td>{m.created_at}</td>
<td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td> <td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td>
<td><a href="/parts/{m.part_id}">{m.sku}</a></td> <td><a href="/parts/{m.part_id}">{localized(m, 'name', lang)}</a></td>
<td>{localized(m, 'name', lang)}</td>
<td class="num">{m.quantity}</td> <td class="num">{m.quantity}</td>
</tr> </tr>
{/each} {/each}

View File

@ -80,7 +80,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('reports.units_sold')}</th> <th class="num">{$t('reports.units_sold')}</th>
<th class="num">{$t('reports.sale')}</th> <th class="num">{$t('reports.sale')}</th>
@ -91,8 +90,7 @@
<tbody> <tbody>
{#each topParts as p} {#each topParts as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td> <td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td class="num">{p.units_sold}</td> <td class="num">{p.units_sold}</td>
<td class="num"> <td class="num">
{formatMoney(p.sale_dirams, lang)} {formatMoney(p.sale_dirams, lang)}

View File

@ -7,7 +7,7 @@
function lineLabel(line) { function lineLabel(line) {
if (line.affects_inventory === 0) return line.label; 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);
} }
</script> </script>

View File

@ -19,7 +19,6 @@
const q = partSearch.trim().toLowerCase(); const q = partSearch.trim().toLowerCase();
if (!q) return parts; if (!q) return parts;
return parts.filter((p) => return parts.filter((p) =>
(p.sku || '').toLowerCase().includes(q) ||
(p.name_en || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) ||
(p.name_tg || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) ||
(p.barcode || '').toLowerCase().includes(q) (p.barcode || '').toLowerCase().includes(q)
@ -59,7 +58,7 @@
function lineLabel(line) { function lineLabel(line) {
if (line.affects_inventory === 0) return line.label; 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) { function confirmCancel(event) {
@ -88,7 +87,7 @@
<option value=""></option> <option value=""></option>
{#each visibleParts as p} {#each visibleParts as p}
<option value={String(p.id)}> <option value={String(p.id)}>
{p.sku}{localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand}) {localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
</option> </option>
{/each} {/each}
</select> </select>

View File

@ -21,7 +21,6 @@
const q = partSearch.trim().toLowerCase(); const q = partSearch.trim().toLowerCase();
if (!q) return parts; if (!q) return parts;
return parts.filter((p) => return parts.filter((p) =>
(p.sku || '').toLowerCase().includes(q) ||
(p.name_en || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) ||
(p.name_tg || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) ||
(p.barcode || '').toLowerCase().includes(q) (p.barcode || '').toLowerCase().includes(q)
@ -105,7 +104,7 @@
<option value=""></option> <option value=""></option>
{#each visibleParts as p} {#each visibleParts as p}
<option value={String(p.id)}> <option value={String(p.id)}>
{p.sku}{localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand}) {localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
</option> </option>
{/each} {/each}
</select> </select>

View File

@ -2,7 +2,7 @@ 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') ?? 'name_en';
const dir = url.searchParams.get('dir') ?? 'asc'; const dir = url.searchParams.get('dir') ?? 'asc';
const cat = url.searchParams.get('category') ?? ''; const cat = url.searchParams.get('category') ?? '';
const categoryIds = cat const categoryIds = cat

View File

@ -15,7 +15,7 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
if (qNext) params.set('q', qNext); if (qNext) params.set('q', qNext);
if (catsNext.length) params.set('category', catsNext.join(',')); 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); if (dirNext && dirNext !== 'asc') params.set('dir', dirNext);
const target = '/parts' + (params.toString() ? '?' + params.toString() : ''); const target = '/parts' + (params.toString() ? '?' + params.toString() : '');
goto(target, { replaceState: true, keepFocus: true, noScroll: true }); goto(target, { replaceState: true, keepFocus: true, noScroll: true });
@ -99,7 +99,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th><button class="th-btn" on:click={() => sortBy('sku')}>{$t('parts.sku')} {arrow('sku')}</button></th>
<th><button class="th-btn" on:click={() => sortBy(lang === 'tg' ? 'name_tg' : 'name_en')}>{$t('parts.name')} {arrow(lang === 'tg' ? 'name_tg' : 'name_en')}</button></th> <th><button class="th-btn" on:click={() => sortBy(lang === 'tg' ? 'name_tg' : 'name_en')}>{$t('parts.name')} {arrow(lang === 'tg' ? 'name_tg' : 'name_en')}</button></th>
<th>{$t('parts.category')}</th> <th>{$t('parts.category')}</th>
<th class="num"><button class="th-btn" on:click={() => sortBy('quantity_on_hand')}>{$t('parts.quantity_on_hand')} {arrow('quantity_on_hand')}</button></th> <th class="num"><button class="th-btn" on:click={() => sortBy('quantity_on_hand')}>{$t('parts.quantity_on_hand')} {arrow('quantity_on_hand')}</button></th>
@ -110,9 +109,8 @@
<tbody> <tbody>
{#each parts as p} {#each parts as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td> <td>
{localized(p, 'name', lang)} <a href="/parts/{p.id}">{localized(p, 'name', lang)}</a>
{#if !hasTranslation(p, 'name', lang)} {#if !hasTranslation(p, 'name', lang)}
<em class="missing">{$t('common.missing_translation')}</em> <em class="missing">{$t('common.missing_translation')}</em>
{/if} {/if}

View File

@ -1,5 +1,5 @@
import { error, fail, redirect } from '@sveltejs/kit'; 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'; import { recentMovementsForPart } from '$lib/server/movements.js';
export function load({ params }) { export function load({ params }) {
@ -14,21 +14,23 @@ export function load({ params }) {
} }
export const actions = { export const actions = {
default: async ({ request, params }) => { update: async ({ request, params }) => {
const id = Number(params.id); const id = Number(params.id);
const form = await request.formData(); const form = await request.formData();
const data = Object.fromEntries(form); const data = Object.fromEntries(form);
const errors = {}; 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())) { if ((!data.name_en || !data.name_en.trim()) && (!data.name_tg || !data.name_tg.trim())) {
errors.name = 'parts.errors.name_required'; 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 }); if (Object.keys(errors).length) return fail(400, { errors, values: data });
updatePart(id, data); updatePart(id, data);
throw redirect(303, `/parts/${id}`); throw redirect(303, `/parts/${id}`);
},
delete: async ({ params }) => {
deactivatePart(Number(params.id));
throw redirect(303, '/parts');
} }
}; };

View File

@ -1,4 +1,5 @@
<script> <script>
import { enhance } from '$app/forms';
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js'; import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
export let data; export let data;
@ -6,6 +7,12 @@
$: lang = $locale; $: lang = $locale;
$: ({ part, categories, movements } = data); $: ({ part, categories, movements } = data);
function confirmDelete(event) {
const name = localized(part, 'name', lang) || String(part.id);
const message = $t('parts.delete_confirm').replace('{name}', name);
if (!confirm(message)) event.preventDefault();
}
$: errors = form?.errors ?? {}; $: errors = form?.errors ?? {};
$: values = form?.values ?? {}; $: values = form?.values ?? {};
@ -17,7 +24,7 @@
</script> </script>
<div class="page-head"> <div class="page-head">
<h1>{$t('parts.edit')}: {part.sku}</h1> <h1>{$t('parts.edit')}: {localized(part, 'name', lang) || part.id}</h1>
<a href="/parts" class="muted">{$t('common.back')}</a> <a href="/parts" class="muted">{$t('common.back')}</a>
</div> </div>
@ -27,13 +34,7 @@
<div class="layout"> <div class="layout">
<section> <section>
<form class="stack" method="POST"> <form class="stack" method="POST" action="?/update">
<label>
{$t('parts.sku')} *
<input name="sku" required value={values.sku ?? part.sku} />
{#if errors.sku}<span class="field-error">{$t(errors.sku)}</span>{/if}
</label>
<div class="row"> <div class="row">
<label> <label>
{$t('parts.name_en')} {$t('parts.name_en')}
@ -83,27 +84,10 @@
</label> </label>
</div> </div>
<div class="row"> <label>
<label> {$t('parts.barcode')}
{$t('parts.location')} <input name="barcode" value={values.barcode ?? part.barcode ?? ''} />
<input name="location" value={values.location ?? part.location ?? ''} /> </label>
</label>
<label>
{$t('parts.barcode')}
<input name="barcode" value={values.barcode ?? part.barcode ?? ''} />
</label>
</div>
<div class="row">
<label>
{$t('parts.description_en')}
<textarea name="description_en">{values.description_en ?? part.description_en ?? ''}</textarea>
</label>
<label>
{$t('parts.description_tg')}
<textarea name="description_tg">{values.description_tg ?? part.description_tg ?? ''}</textarea>
</label>
</div>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="active" value="1" <input type="checkbox" name="active" value="1"
@ -116,6 +100,10 @@
<a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a> <a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a>
</div> </div>
</form> </form>
<form method="POST" action="?/delete" class="delete-form" use:enhance on:submit={confirmDelete}>
<button type="submit" class="danger">{$t('common.delete')}</button>
</form>
</section> </section>
<aside> <aside>
@ -186,6 +174,11 @@
.btn-link:hover { background: #00553e; color: #fff; } .btn-link:hover { background: #00553e; color: #fff; }
.checkbox { display: flex; align-items: center; gap: 0.4rem; } .checkbox { display: flex; align-items: center; gap: 0.4rem; }
.field-error { color: #8a1f1b; font-size: 0.8rem; } .field-error { color: #8a1f1b; font-size: 0.8rem; }
.delete-form {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #eef0f5;
}
.qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; } .qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; }
.qty.low { color: #b8443f; } .qty.low { color: #b8443f; }

View File

@ -1,5 +1,5 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { createPart, getPartBySku, listCategories } from '$lib/server/parts.js'; import { createPart, listCategories } from '$lib/server/parts.js';
import { recordMovement } from '$lib/server/movements.js'; import { recordMovement } from '$lib/server/movements.js';
export function load() { export function load() {
@ -13,10 +13,6 @@ export const actions = {
const errors = validate(data); const errors = validate(data);
if (errors) return fail(400, { errors, values: data }); if (errors) return fail(400, { errors, values: data });
if (getPartBySku(data.sku.trim())) {
return fail(400, { errors: { sku: 'parts.errors.sku_taken' }, values: data });
}
// Save the part with quantity 0, then record an opening "in" movement // Save the part with quantity 0, then record an opening "in" movement
// if the user supplied an initial quantity. This keeps quantity changes // if the user supplied an initial quantity. This keeps quantity changes
// funneled exclusively through stock_movements. // funneled exclusively through stock_movements.
@ -38,7 +34,6 @@ export const actions = {
function validate(d) { function validate(d) {
const errors = {}; const errors = {};
if (!d.sku || !d.sku.trim()) errors.sku = 'parts.errors.sku_required';
if ((!d.name_en || !d.name_en.trim()) && (!d.name_tg || !d.name_tg.trim())) { if ((!d.name_en || !d.name_en.trim()) && (!d.name_tg || !d.name_tg.trim())) {
errors.name = 'parts.errors.name_required'; errors.name = 'parts.errors.name_required';
} }

View File

@ -17,12 +17,6 @@
{/if} {/if}
<form class="stack" method="POST"> <form class="stack" method="POST">
<label>
{$t('parts.sku')} *
<input name="sku" required value={values.sku ?? ''} />
{#if errors.sku}<span class="field-error">{$t(errors.sku)}</span>{/if}
</label>
<div class="row"> <div class="row">
<label> <label>
{$t('parts.name_en')} {$t('parts.name_en')}
@ -74,24 +68,8 @@
<input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} /> <input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} />
</label> </label>
<label> <label>
{$t('parts.location')} {$t('parts.barcode')}
<input name="location" value={values.location ?? ''} /> <input name="barcode" value={values.barcode ?? ''} />
</label>
</div>
<label>
{$t('parts.barcode')}
<input name="barcode" value={values.barcode ?? ''} />
</label>
<div class="row">
<label>
{$t('parts.description_en')}
<textarea name="description_en">{values.description_en ?? ''}</textarea>
</label>
<label>
{$t('parts.description_tg')}
<textarea name="description_tg">{values.description_tg ?? ''}</textarea>
</label> </label>
</div> </div>