Compare commits

..

11 Commits

Author SHA1 Message Date
0314e87c3f remove patch file 2026-06-17 20:46:15 +05:00
32331d4cf8 Merge branch 'master' of ssh://gitea.rareobj.com:222/dab/avtoambor 2026-06-17 20:41:13 +05:00
a85979731f use tajik timezone for displaying dates 2026-06-17 20:40:43 +05:00
45ef55e13e fix favicon 2026-06-17 18:43:05 +05:00
0000748fe0 add favicon 2026-06-17 13:40:19 +00:00
a287d26b93 fix gitignore and install bat 2026-06-17 18:06:29 +05:00
d4cba18017 Merge branch 'master' of https://gitea.rareobj.com/dab/avtoambor 2026-06-17 18:05:04 +05:00
259f8d4b8f update gitignore 2026-05-23 22:46:09 +05:00
66e15dee1f change default unit to liter
Since the inventory is mostly containing enginge oils, which are sold per liter, the default unit for a new part is set to liter
2026-05-23 16:43:39 +05:00
aac71becfc remove the invetory sale value from report
Since product prices can be negotiated with every client, the inventory value at sale cannot be determined. Removing the number from the report but keeping the placeholder.
2026-05-23 16:16:49 +05:00
8cbaa55b48 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>
2026-05-18 20:49:53 +05:00
19 changed files with 117 additions and 141 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ backups/
*.sw?
.session.vim
.claude/settings.local.json
*~
/*.bat

View File

@ -6,16 +6,6 @@ chcp 65001 >nul
setlocal
cd /d "%~dp0"
if exist "%ProgramFiles%\nodejs\node.exe" (
echo Node.js уже установлен в %ProgramFiles%\nodejs.
echo Пропускаем установку.
goto :done
)
if exist "%ProgramFiles(x86)%\nodejs\node.exe" (
echo Node.js уже установлен в %ProgramFiles(x86)%\nodejs.
echo Пропускаем установку.
goto :done
)
set "MSI=node-v16.20.2-x64.msi"
if /i "%PROCESSOR_ARCHITECTURE%"=="x86" if not defined PROCESSOR_ARCHITEW6432 set "MSI=node-v16.20.2-x86.msi"

View File

@ -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,10 +143,9 @@
"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)",
"low_stock": "Low stock",
"out_of_stock": "Out of stock",
"top_parts": "Top selling parts",

View File

@ -85,3 +85,20 @@ export function formatMoney(dirams, lang = 'en') {
const s = n.toFixed(2);
return lang === 'tg' ? s.replace('.', ',') : s;
}
export function formatTs(utcStr) {
if (!utcStr) return '';
const normalized = String(utcStr).trim().replace(' ', 'T');
const utcDate = new Date(`${normalized}Z`);
if (Number.isNaN(utcDate.getTime())) return utcStr;
const tajikDate = new Date(utcDate.getTime() + 5 * 60 * 60 * 1000);
const pad = (n) => String(n).padStart(2, '0');
return [
pad(tajikDate.getUTCDate()),
pad(tajikDate.getUTCMonth() + 1),
tajikDate.getUTCFullYear()
].join('.') + ` ${pad(tajikDate.getUTCHours())}:${pad(tajikDate.getUTCMinutes())}`;
}

View File

@ -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,10 +143,9 @@
"this_month": "Ин моҳ",
"all_time": "Тамоми давра",
"invoices": "фактура",
"active_skus": "SKU-ҳои фаъол",
"active_skus": "Қисмҳои фаъол",
"units_on_hand": "Дар анбор",
"cost_value": "Арзиш (бо нархи харид)",
"sale_value": "Арзиш (бо нархи фурӯш)",
"low_stock": "Захираи кам",
"out_of_stock": "Тамом шуд",
"top_parts": "Қисмҳои серфурӯш",

View File

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

View File

@ -1,6 +1,6 @@
import { getDb } from './db.js';
// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`.
// All time windows are computed in Tajikistan time (UTC+5) while timestamps are stored as UTC.
// Cost of goods (COG) and profit are computed against each part's current cost_price —
// the schema does not snapshot cost at sale time, so historical cost changes are not
// reflected. Custom (non-inventory) lines contribute to sale revenue but have zero COG.
@ -29,9 +29,9 @@ function windowStats(dateClause) {
export function salesSummary() {
return {
all_time: windowStats(''),
today: windowStats(`date(saved_at, 'localtime') = date('now', 'localtime')`),
week: windowStats(`date(saved_at, 'localtime') >= date('now', 'localtime', '-6 days')`),
month: windowStats(`strftime('%Y-%m', saved_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`)
today: windowStats(`date(saved_at, '+5 hours') = date('now', '+5 hours')`),
week: windowStats(`date(saved_at, '+5 hours') >= date('now', '+5 hours', '-6 days')`),
month: windowStats(`strftime('%Y-%m', saved_at, '+5 hours') = strftime('%Y-%m', 'now', '+5 hours')`)
};
}

View File

@ -1,5 +1,5 @@
<script>
import { locale, t, localized } from '$lib/i18n/store.js';
import { locale, t, localized, formatTs } from '$lib/i18n/store.js';
export let data;
$: lang = $locale;
@ -15,7 +15,6 @@
<table>
<thead>
<tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th>
<th class="num">{$t('parts.quantity_on_hand')}</th>
<th class="num">{$t('parts.reorder_level')}</th>
@ -24,8 +23,7 @@
<tbody>
{#each lowStock as p}
<tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td class="num"><span class="pill low">{p.quantity_on_hand}</span></td>
<td class="num">{p.reorder_level}</td>
</tr>
@ -43,7 +41,6 @@
<tr>
<th>{$t('movements.created_at')}</th>
<th>{$t('movements.type')}</th>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th>
<th class="num">{$t('movements.quantity')}</th>
</tr>
@ -51,10 +48,9 @@
<tbody>
{#each movements as m}
<tr>
<td>{m.created_at}</td>
<td>{formatTs(m.created_at)}</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>{localized(m, 'name', lang)}</td>
<td><a href="/parts/{m.part_id}">{localized(m, 'name', lang)}</a></td>
<td class="num">{m.quantity}</td>
</tr>
{/each}

View File

@ -1,16 +1,10 @@
<script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data;
$: lang = $locale;
$: ({ sales, topParts, inventory, recentSales } = data);
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script>
<h2>{$t('reports.sales_heading')}</h2>
@ -56,13 +50,7 @@
<span class="cur">{$t('common.currency_short')}</span>
</div>
</div>
<div class="card stat">
<div class="label">{$t('reports.sale_value')}</div>
<div class="value">
{formatMoney(inventory.sale_value_dirams, lang)}
<span class="cur">{$t('common.currency_short')}</span>
</div>
</div>
<div></div>
<div class="card stat">
<div class="label">{$t('reports.low_stock')}</div>
<div class="value" class:warn={inventory.lowStockCount > 0}>{inventory.lowStockCount}</div>
@ -80,7 +68,6 @@
<table>
<thead>
<tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th>
<th class="num">{$t('reports.units_sold')}</th>
<th class="num">{$t('reports.sale')}</th>
@ -91,8 +78,7 @@
<tbody>
{#each topParts as p}
<tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td class="num">{p.units_sold}</td>
<td class="num">
{formatMoney(p.sale_dirams, lang)}
@ -130,7 +116,7 @@
<tbody>
{#each recentSales as s}
<tr>
<td>{formatWhen(s.saved_at)}</td>
<td>{formatTs(s.saved_at)}</td>
<td class="num">{s.line_count}</td>
<td class="num">
{formatMoney(s.sale_dirams, lang)}

View File

@ -1,5 +1,5 @@
<script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data;
$: lang = $locale;
@ -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);
}
</script>
@ -15,7 +15,7 @@
<header class="head">
<div>
<h1>{$t('invoices.saved_title')} #{invoice.id}</h1>
<p class="muted">{invoice.saved_at}</p>
<p class="muted">{formatTs(invoice.saved_at)}</p>
</div>
<a href="/invoices/new" class="print-hide back">{$t('invoices.new_another')}</a>
</header>

View File

@ -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 @@
<option value=""></option>
{#each visibleParts as p}
<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>
{/each}
</select>

View File

@ -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 @@
<option value=""></option>
{#each visibleParts as p}
<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>
{/each}
</select>

View File

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

View File

@ -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 @@
<table>
<thead>
<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>{$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>
@ -110,9 +109,8 @@
<tbody>
{#each parts as p}
<tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td>
{localized(p, 'name', lang)}
<a href="/parts/{p.id}">{localized(p, 'name', lang)}</a>
{#if !hasTranslation(p, 'name', lang)}
<em class="missing">{$t('common.missing_translation')}</em>
{/if}

View File

@ -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');
}
};

View File

@ -1,11 +1,18 @@
<script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
import { enhance } from '$app/forms';
import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data;
export let form;
$: lang = $locale;
$: ({ 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 ?? {};
$: values = form?.values ?? {};
@ -17,7 +24,7 @@
</script>
<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>
</div>
@ -27,13 +34,7 @@
<div class="layout">
<section>
<form class="stack" method="POST">
<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>
<form class="stack" method="POST" action="?/update">
<div class="row">
<label>
{$t('parts.name_en')}
@ -83,27 +84,10 @@
</label>
</div>
<div class="row">
<label>
{$t('parts.location')}
<input name="location" value={values.location ?? part.location ?? ''} />
</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">
<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>
</div>
</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>
<aside>
@ -129,8 +117,8 @@
{$t('parts.reorder_level')}: {part.reorder_level}
</div>
<hr />
<div class="muted small">{$t('common.created')}: {part.created_at}</div>
<div class="muted small">{$t('common.updated')}: {part.updated_at}</div>
<div class="muted small">{$t('common.created')}: {formatTs(part.created_at)}</div>
<div class="muted small">{$t('common.updated')}: {formatTs(part.updated_at)}</div>
</div>
<h2>{$t('parts.recent_movements')}</h2>
@ -149,7 +137,7 @@
<tbody>
{#each movements as m}
<tr>
<td>{m.created_at}</td>
<td>{formatTs(m.created_at)}</td>
<td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td>
<td class="num">{m.quantity > 0 ? '+' : ''}{m.quantity}</td>
<td class="num">{m.unit_price != null ? formatMoney(m.unit_price, lang) : $t('common.none')}</td>
@ -186,6 +174,11 @@
.btn-link:hover { background: #00553e; color: #fff; }
.checkbox { display: flex; align-items: center; gap: 0.4rem; }
.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.low { color: #b8443f; }

View File

@ -1,5 +1,5 @@
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';
export function load() {
@ -13,10 +13,6 @@ export const actions = {
const errors = validate(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
// if the user supplied an initial quantity. This keeps quantity changes
// funneled exclusively through stock_movements.
@ -38,7 +34,6 @@ export const actions = {
function validate(d) {
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())) {
errors.name = 'parts.errors.name_required';
}

View File

@ -17,12 +17,6 @@
{/if}
<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">
<label>
{$t('parts.name_en')}
@ -60,7 +54,7 @@
<div class="row">
<label>
{$t('parts.unit')}
<input name="unit" value={values.unit ?? 'pcs'} />
<input name="unit" value={values.unit ?? 'liter'} />
</label>
<label>
{$t('parts.reorder_level')}
@ -73,26 +67,10 @@
{$t('parts.initial_quantity')}
<input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} />
</label>
<label>
{$t('parts.location')}
<input name="location" value={values.location ?? ''} />
</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>
</div>
<div class="actions">

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB