Initial scaffold for AvtoAmbor parts inventory

SvelteKit 2 + Svelte 4 + adapter-node, SQLite via better-sqlite3 (WAL,
foreign keys on). Bilingual EN/Тоҷикӣ throughout, locale persisted in
localStorage.

Pages: dashboard (totals, low stock, recent movements), parts list with
search and sort, part create/edit, record movement (in/out/adjust with
smart unit-price and adjust-quantity prefill), suppliers list with
inline add.

Schema: categories, suppliers, parts (with _en/_tg name+description
columns, dirams for money), stock_movements with check on movement_type.
On-hand updates are done in JS inside a transaction with the movement
insert.

Dockerized dev: docker compose, named project, bind-mounted data/ for
DB persistence. Seed contains 6 categories, 4 suppliers, 31 realistic
parts (Lada / Nexia / Opel / Toyota bias).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
David Beccue
2026-05-16 07:05:24 +05:00
commit 05be5b03aa
37 changed files with 4617 additions and 0 deletions

13
src/app.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#c8102e" />
<title>AvtoAmbor</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

10
src/hooks.server.js Normal file
View File

@ -0,0 +1,10 @@
import { getDb } from '$lib/server/db.js';
// Open (and warm) the database on server startup so the first request
// doesn't pay the cost.
getDb();
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
return resolve(event);
}

View File

@ -0,0 +1,98 @@
<script>
import { locale, t, toggleLocale } from '$lib/i18n/store.js';
import { page } from '$app/stores';
$: lang = $locale;
$: path = $page.url.pathname;
function isActive(prefix) {
if (prefix === '/') return path === '/';
return path === prefix || path.startsWith(prefix + '/');
}
</script>
<header class="header">
<a class="brand" href="/">
<span class="wordmark">
{#if lang === 'tg'}АвтоАмбор{:else}AvtoAmbor{/if}
</span>
<span class="tagline">{$t('app.tagline')}</span>
</a>
<nav class="nav">
<a href="/" class:active={isActive('/')}>{$t('nav.dashboard')}</a>
<a href="/parts" class:active={isActive('/parts')}>{$t('nav.parts')}</a>
<a href="/movements/new" class:active={isActive('/movements')}>{$t('nav.movements')}</a>
<a href="/suppliers" class:active={isActive('/suppliers')}>{$t('nav.suppliers')}</a>
</nav>
<button class="lang" type="button" on:click={toggleLocale} aria-label="Switch language">
{lang === 'en' ? $t('lang.switch_to_tg') : $t('lang.switch_to_en')}
</button>
</header>
<style>
.header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(180deg, #c8102e 0%, #b00d27 100%);
color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
border-bottom: 1px solid #8e0a1f;
}
.brand {
display: flex;
align-items: baseline;
gap: 0.6rem;
text-decoration: none;
color: inherit;
}
.wordmark {
font-size: 1.45rem;
font-weight: 700;
letter-spacing: 0.6px;
/* Cyrillic-friendly serif for a slightly more "Tajik" feel */
font-family: "Noto Serif", "Times New Roman", Georgia, serif;
}
.tagline {
font-size: 0.8rem;
opacity: 0.9;
font-style: italic;
}
.nav {
display: flex;
gap: 1rem;
margin-left: 1rem;
flex: 1;
}
.nav a {
color: inherit;
text-decoration: none;
padding: 0.3rem 0.6rem;
border-radius: 4px;
opacity: 0.9;
}
.nav a:hover { opacity: 1; background: rgba(255,255,255,0.1); }
.nav a.active {
background: rgba(255,255,255,0.22);
opacity: 1;
}
.lang {
background: rgba(255,255,255,0.18);
color: inherit;
border: 1px solid rgba(255,255,255,0.45);
padding: 0.35rem 0.85rem;
border-radius: 4px;
font: inherit;
cursor: pointer;
}
.lang:hover { background: rgba(255,255,255,0.28); }
@media (max-width: 640px) {
.header { flex-wrap: wrap; }
.tagline { display: none; }
.nav { order: 3; flex-basis: 100%; margin-left: 0; }
}
</style>

113
src/lib/i18n/en.json Normal file
View File

@ -0,0 +1,113 @@
{
"app": {
"name": "AvtoAmbor",
"tagline": "Auto parts inventory"
},
"nav": {
"dashboard": "Dashboard",
"parts": "Parts",
"movements": "Movements",
"suppliers": "Suppliers",
"new_part": "New part",
"new_movement": "Record movement"
},
"lang": {
"switch_to_tg": "Тоҷикӣ",
"switch_to_en": "English"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"search": "Search",
"clear": "Clear",
"actions": "Actions",
"back": "Back",
"yes": "Yes",
"no": "No",
"loading": "Loading…",
"none": "—",
"edit": "Edit",
"add": "Add",
"submit": "Submit",
"created": "Created",
"updated": "Updated",
"value": "Value",
"total": "Total",
"currency_short": "TJS",
"missing_translation": "(missing translation)"
},
"dashboard": {
"title": "Dashboard",
"total_skus": "Total SKUs",
"low_stock": "At or below reorder level",
"inventory_value": "Inventory value (at cost)",
"low_stock_list": "Low stock",
"recent_movements": "Recent movements",
"quick_actions": "Quick actions"
},
"parts": {
"title": "Parts",
"new": "New part",
"edit": "Edit part",
"sku": "SKU",
"name": "Name",
"name_en": "Name (English)",
"name_tg": "Name (Tajik)",
"description": "Description",
"description_en": "Description (English)",
"description_tg": "Description (Tajik)",
"category": "Category",
"unit": "Unit",
"cost_price": "Cost price",
"sale_price": "Sale price",
"quantity_on_hand": "On hand",
"reorder_level": "Reorder level",
"location": "Location",
"barcode": "Barcode",
"active": "Active",
"search_placeholder": "Search by SKU, name, or barcode…",
"no_results": "No parts match your search.",
"recent_movements": "Recent movements",
"initial_quantity": "Initial quantity",
"errors": {
"sku_required": "SKU is required.",
"name_required": "At least one name (English or Tajik) is required.",
"sku_taken": "That SKU is already used."
}
},
"movements": {
"title": "Stock movements",
"new": "Record movement",
"type": "Type",
"type_in": "In (receive)",
"type_out": "Out (sale / use)",
"type_adjust": "Adjust (set on-hand)",
"part": "Part",
"quantity": "Quantity",
"unit_price": "Unit price",
"supplier": "Supplier",
"reference": "Reference",
"notes": "Notes",
"created_at": "When",
"no_movements": "No movements recorded yet.",
"errors": {
"part_required": "Pick a part.",
"quantity_required": "Quantity must be a positive whole number.",
"not_enough_stock": "Not enough stock on hand."
}
},
"suppliers": {
"title": "Suppliers",
"name": "Name",
"phone": "Phone",
"address": "Address",
"notes": "Notes",
"add": "Add supplier",
"no_suppliers": "No suppliers yet.",
"delete_confirm": "Delete this supplier?",
"errors": {
"name_required": "Supplier name is required."
}
}
}

87
src/lib/i18n/store.js Normal file
View File

@ -0,0 +1,87 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import en from './en.json';
import tg from './tg.json';
const DICTS = { en, tg };
const STORAGE_KEY = 'avtoambor.locale';
const DEFAULT_LOCALE = 'tg';
// Warn at most once per missing key, so the console doesn't flood.
const _warned = new Set();
function lookup(dict, key) {
const parts = key.split('.');
let v = dict;
for (const p of parts) {
if (v == null) return undefined;
v = v[p];
}
return v;
}
export const locale = writable(
(browser && localStorage.getItem(STORAGE_KEY)) || DEFAULT_LOCALE
);
if (browser) {
locale.subscribe((value) => {
try { localStorage.setItem(STORAGE_KEY, value); } catch { /* ignore */ }
document.documentElement.setAttribute('lang', value);
});
}
export const t = derived(locale, ($locale) => {
return (key) => {
const primary = lookup(DICTS[$locale], key);
if (primary != null) return primary;
const fallback = lookup(DICTS.en, key);
if (fallback != null) {
if (!_warned.has(key)) {
_warned.add(key);
console.warn(`[i18n] missing "${key}" for locale "${$locale}"; using English.`);
}
return fallback;
}
if (!_warned.has(key)) {
_warned.add(key);
console.warn(`[i18n] missing key "${key}"`);
}
return key;
};
});
export function toggleLocale() {
locale.update((v) => (v === 'en' ? 'tg' : 'en'));
}
// Pick the right column from a record that has both _en and _tg fields,
// e.g. localized(part, 'name', $locale) → part.name_tg or part.name_en.
// Falls back to whichever language has content (not just English) so a
// TG-only entry still renders for an EN viewer.
export function localized(record, baseField, lang) {
if (!record) return '';
return (
record[`${baseField}_${lang}`] ||
record[`${baseField}_en`] ||
record[`${baseField}_tg`] ||
''
);
}
// True if the record has a non-empty value in the requested language.
// Used to flag "(missing translation)" when we had to fall back.
export function hasTranslation(record, baseField, lang) {
if (!record) return false;
const v = record[`${baseField}_${lang}`];
return v != null && String(v).trim() !== '';
}
// Money helpers: dirams ↔ display string.
export function formatMoney(dirams, lang = 'en') {
if (dirams == null) return '';
const n = Number(dirams) / 100;
// Tajik uses comma decimal separator in everyday use; English uses period.
const s = n.toFixed(2);
return lang === 'tg' ? s.replace('.', ',') : s;
}

113
src/lib/i18n/tg.json Normal file
View File

@ -0,0 +1,113 @@
{
"app": {
"name": "AvtoAmbor",
"tagline": "Захираи қисмҳои эҳтиётии мошин"
},
"nav": {
"dashboard": "Лавҳаи асосӣ",
"parts": "Қисмҳо",
"movements": "Ҳаракатҳо",
"suppliers": "Таъминкунандагон",
"new_part": "Қисми нав",
"new_movement": "Сабти ҳаракат"
},
"lang": {
"switch_to_tg": "Тоҷикӣ",
"switch_to_en": "English"
},
"common": {
"save": "Захира",
"cancel": "Бекор",
"delete": "Нест кардан",
"search": "Ҷустуҷӯ",
"clear": "Пок кардан",
"actions": "Амалҳо",
"back": "Бозгашт",
"yes": "Ҳа",
"no": "Не",
"loading": "Боркунӣ…",
"none": "—",
"edit": "Тағйир додан",
"add": "Илова",
"submit": "Тасдиқ",
"created": "Сохта шуд",
"updated": "Нав карда шуд",
"value": "Арзиш",
"total": "Ҳамагӣ",
"currency_short": "сом.",
"missing_translation": "(тарҷума нест)"
},
"dashboard": {
"title": "Лавҳаи асосӣ",
"total_skus": "Ҳамаи SKU-ҳо",
"low_stock": "Дар сатҳи фармоиш ё камтар",
"inventory_value": "Арзиши захира (бо нархи харид)",
"low_stock_list": "Захираи кам",
"recent_movements": "Ҳаракатҳои охирин",
"quick_actions": "Амалҳои тез"
},
"parts": {
"title": "Қисмҳо",
"new": "Қисми нав",
"edit": "Тағйири қисм",
"sku": "SKU",
"name": "Ном",
"name_en": "Ном (англисӣ)",
"name_tg": "Ном (тоҷикӣ)",
"description": "Тавсиф",
"description_en": "Тавсиф (англисӣ)",
"description_tg": "Тавсиф (тоҷикӣ)",
"category": "Категория",
"unit": "Воҳид",
"cost_price": "Нархи харид",
"sale_price": "Нархи фурӯш",
"quantity_on_hand": "Дар анбор",
"reorder_level": "Сатҳи фармоиш",
"location": "Ҷой",
"barcode": "Штрих-код",
"active": "Фаъол",
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…",
"no_results": "Ҳеҷ қисм мувофиқат намекунад.",
"recent_movements": "Ҳаракатҳои охирин",
"initial_quantity": "Шумораи аввала",
"errors": {
"sku_required": "SKU зарур аст.",
"name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.",
"sku_taken": "Ин SKU аллакай истифода шудааст."
}
},
"movements": {
"title": "Ҳаракатҳои захира",
"new": "Сабти ҳаракат",
"type": "Намуд",
"type_in": "Воридот (қабул)",
"type_out": "Содирот (фурӯш / истеъмол)",
"type_adjust": "Танзим (миқдор гузоштан)",
"part": "Қисм",
"quantity": "Миқдор",
"unit_price": "Нархи воҳид",
"supplier": "Таъминкунанда",
"reference": "Рамзи ҳуҷҷат",
"notes": "Эзоҳ",
"created_at": "Сана",
"no_movements": "Ҳоло ҳаракате сабт нашудааст.",
"errors": {
"part_required": "Қисмро интихоб кунед.",
"quantity_required": "Миқдор бояд бутун ва мусбат бошад.",
"not_enough_stock": "Дар анбор миқдори кофӣ нест."
}
},
"suppliers": {
"title": "Таъминкунандагон",
"name": "Ном",
"phone": "Телефон",
"address": "Суроға",
"notes": "Эзоҳ",
"add": "Илова кардани таъминкунанда",
"no_suppliers": "Ҳоло таъминкунандае нест.",
"delete_confirm": "Ин таъминкунандаро нест мекунед?",
"errors": {
"name_required": "Номи таъминкунанда зарур аст."
}
}
}

23
src/lib/server/db.js Normal file
View File

@ -0,0 +1,23 @@
import Database from 'better-sqlite3';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { mkdirSync, existsSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// data/ lives at the repo root regardless of where the server is launched from.
const DB_DIR = resolve(__dirname, '../../../data');
const DB_PATH = resolve(DB_DIR, 'avtoambor.db');
let _db;
export function getDb() {
if (_db) return _db;
if (!existsSync(DB_DIR)) mkdirSync(DB_DIR, { recursive: true });
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
return _db;
}
export const DB_FILE = DB_PATH;

View File

@ -0,0 +1,90 @@
import { getDb } from './db.js';
/**
* Record a stock movement and update parts.quantity_on_hand atomically.
* Returns the new on-hand quantity.
*
* type: 'in' | 'out' | 'adjust'
* quantity: positive integer
* - 'in': adds to on-hand
* - 'out': subtracts from on-hand (stored as negative)
* - 'adjust': sets on-hand to exactly this number (delta stored)
*/
export function recordMovement(input) {
const db = getDb();
const partId = Number(input.part_id);
const type = input.movement_type;
const qty = Math.abs(Number(input.quantity || 0));
const unitPrice = toDirams(input.unit_price);
const supplierId = input.supplier_id ? Number(input.supplier_id) : null;
const reference = input.reference?.trim() || null;
const notes = input.notes?.trim() || null;
if (!partId) throw new Error('part_id required');
if (!['in','out','adjust'].includes(type)) throw new Error('invalid movement_type');
if (!Number.isInteger(qty) || qty < 0) throw new Error('quantity must be a non-negative integer');
const tx = db.transaction(() => {
const part = db.prepare(`SELECT id, quantity_on_hand FROM parts WHERE id = ?`).get(partId);
if (!part) throw new Error(`part ${partId} not found`);
let storedQty; // what we save in stock_movements.quantity
let newOnHand;
if (type === 'in') {
storedQty = qty;
newOnHand = part.quantity_on_hand + qty;
} else if (type === 'out') {
if (qty > part.quantity_on_hand) {
throw new Error(`not enough stock (have ${part.quantity_on_hand}, need ${qty})`);
}
storedQty = -qty;
newOnHand = part.quantity_on_hand - qty;
} else { // adjust → qty is the new total
storedQty = qty - part.quantity_on_hand;
newOnHand = qty;
}
db.prepare(`
INSERT INTO stock_movements
(part_id, movement_type, quantity, unit_price, supplier_id, reference, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(partId, type, storedQty, unitPrice, supplierId, reference, notes);
db.prepare(`
UPDATE parts SET quantity_on_hand = ?, updated_at = datetime('now') WHERE id = ?
`).run(newOnHand, partId);
return newOnHand;
});
return tx();
}
export function recentMovementsForPart(partId, limit = 20) {
return getDb().prepare(`
SELECT m.*, s.name AS supplier_name
FROM stock_movements m
LEFT JOIN suppliers s ON s.id = m.supplier_id
WHERE m.part_id = ?
ORDER BY m.created_at DESC, m.id DESC
LIMIT ?
`).all(partId, limit);
}
export function recentMovements(limit = 25) {
return getDb().prepare(`
SELECT m.*, p.sku, p.name_en, p.name_tg, s.name AS supplier_name
FROM stock_movements m
JOIN parts p ON p.id = m.part_id
LEFT JOIN suppliers s ON s.id = m.supplier_id
ORDER BY m.created_at DESC, m.id DESC
LIMIT ?
`).all(limit);
}
function toDirams(value) {
if (value === '' || value == null) return null;
const num = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
if (!Number.isFinite(num)) return null;
return Math.round(num * 100);
}

139
src/lib/server/parts.js Normal file
View File

@ -0,0 +1,139 @@
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',
'sale_price', 'cost_price', 'reorder_level', 'updated_at'
]);
export function listParts({ q = '', sort = 'sku', dir = 'asc' } = {}) {
const db = getDb();
const col = SORTABLE.has(sort) ? sort : 'sku';
const order = dir === 'desc' ? 'DESC' : 'ASC';
const where = [];
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)`);
params.q = `%${q.trim()}%`;
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const sql = `
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg
FROM parts p
LEFT JOIN categories c ON c.id = p.category_id
${whereSql}
ORDER BY ${col} ${order}
`;
return db.prepare(sql).all(params);
}
export function getPart(id) {
return getDb().prepare(`
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg
FROM parts p
LEFT JOIN categories c ON c.id = p.category_id
WHERE p.id = ?
`).get(id);
}
export function getPartBySku(sku) {
return getDb().prepare(`SELECT * FROM parts WHERE sku = ?`).get(sku);
}
export function listCategories() {
return getDb()
.prepare(`SELECT * FROM categories ORDER BY sort_order, name_en`)
.all();
}
export function createPart(input) {
const db = getDb();
const stmt = db.prepare(`
INSERT INTO parts
(sku, name_en, name_tg, description_en, description_tg,
category_id, unit, cost_price, sale_price,
quantity_on_hand, reorder_level, location, barcode, active)
VALUES
(@sku, @name_en, @name_tg, @description_en, @description_tg,
@category_id, @unit, @cost_price, @sale_price,
@quantity_on_hand, @reorder_level, @location, @barcode, @active)
`);
const result = stmt.run(normalizePart(input));
return result.lastInsertRowid;
}
export function updatePart(id, input) {
const db = getDb();
const stmt = db.prepare(`
UPDATE parts SET
sku = @sku,
name_en = @name_en,
name_tg = @name_tg,
description_en = @description_en,
description_tg = @description_tg,
category_id = @category_id,
unit = @unit,
cost_price = @cost_price,
sale_price = @sale_price,
reorder_level = @reorder_level,
location = @location,
barcode = @barcode,
active = @active,
updated_at = datetime('now')
WHERE id = @id
`);
// Note: quantity_on_hand is intentionally NOT editable here — it changes
// only through stock_movements.
stmt.run({ ...normalizePart(input), id });
}
function normalizePart(p) {
return {
sku: (p.sku || '').trim(),
name_en: (p.name_en || '').trim(),
name_tg: (p.name_tg || '').trim(),
description_en: p.description_en?.trim() || null,
description_tg: p.description_tg?.trim() || null,
category_id: p.category_id ? Number(p.category_id) : null,
unit: (p.unit || 'pcs').trim(),
cost_price: toDirams(p.cost_price),
sale_price: toDirams(p.sale_price),
quantity_on_hand: Number.isFinite(Number(p.quantity_on_hand)) ? Number(p.quantity_on_hand) : 0,
reorder_level: Number.isFinite(Number(p.reorder_level)) ? Number(p.reorder_level) : 0,
location: p.location?.trim() || null,
barcode: p.barcode?.trim() || null,
active: p.active === false || p.active === '0' || p.active === 'false' ? 0 : 1
};
}
// Accepts somoni-as-string (e.g. "12.50") and returns INTEGER dirams.
function toDirams(value) {
if (value === '' || value == null) return 0;
const num = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
if (!Number.isFinite(num)) return 0;
return Math.round(num * 100);
}
export function dashboardStats() {
const db = getDb();
const total = db.prepare(`SELECT COUNT(*) AS n FROM parts WHERE active = 1`).get().n;
const lowStock = db.prepare(`
SELECT COUNT(*) AS n FROM parts
WHERE active = 1 AND quantity_on_hand <= reorder_level
`).get().n;
const value = db.prepare(`
SELECT COALESCE(SUM(quantity_on_hand * cost_price), 0) AS v FROM parts WHERE active = 1
`).get().v;
return { total, lowStock, inventoryValueDirams: value };
}
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
LIMIT ?
`).all(limit);
}

56
src/lib/server/schema.sql Normal file
View File

@ -0,0 +1,56 @@
-- AvtoAmbor schema. Money is stored as INTEGER dirams (1 TJS = 100 dirams).
-- Translated fields use _en / _tg suffixes.
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_en TEXT NOT NULL,
name_tg TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS suppliers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
address TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name_en TEXT NOT NULL,
name_tg TEXT NOT NULL,
description_en TEXT,
description_tg TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
unit TEXT NOT NULL DEFAULT 'pcs',
cost_price INTEGER NOT NULL DEFAULT 0, -- dirams
sale_price INTEGER NOT NULL DEFAULT 0, -- dirams
quantity_on_hand INTEGER NOT NULL DEFAULT 0,
reorder_level INTEGER NOT NULL DEFAULT 0,
location TEXT,
barcode TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS stock_movements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
movement_type TEXT NOT NULL CHECK(movement_type IN ('in','out','adjust')),
quantity INTEGER NOT NULL, -- positive for in/adjust-up, negative for out/adjust-down
unit_price INTEGER, -- dirams; nullable for adjustments
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
reference TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_parts_sku ON parts(sku);
CREATE INDEX IF NOT EXISTS idx_parts_barcode ON parts(barcode);
CREATE INDEX IF NOT EXISTS idx_parts_category ON parts(category_id);
CREATE INDEX IF NOT EXISTS idx_movements_part ON stock_movements(part_id);
CREATE INDEX IF NOT EXISTS idx_movements_created ON stock_movements(created_at);

190
src/lib/server/seed.sql Normal file
View File

@ -0,0 +1,190 @@
-- Seed data for AvtoAmbor. Prices are in dirams (1 TJS = 100 dirams).
-- Names are biased toward Lada / Daewoo Nexia / Opel / Toyota, which are
-- common in Tajikistan.
INSERT INTO categories (id, name_en, name_tg, sort_order) VALUES
(1, 'Filters', 'Филтрҳо', 10),
(2, 'Brakes', 'Тормоз', 20),
(3, 'Engine', 'Муҳаррик', 30),
(4, 'Electrical', 'Барқӣ', 40),
(5, 'Fluids', 'Моеъҳо', 50),
(6, 'Belts & Hoses', 'Тасма ва шланг', 60);
INSERT INTO suppliers (name, phone, address, notes) VALUES
('Avtomir Dushanbe', '+992 37 221 33 44', 'Dushanbe, Rudaki ave. 112', 'General auto parts wholesale.'),
('Nexia Parts TJ', '+992 92 700 12 34', 'Dushanbe, Korvon market row 8', 'Daewoo / Chevrolet specialist.'),
('Vostok Auto', '+992 93 555 77 11', 'Khujand, Lenin st. 45', 'VAZ / Lada / Niva parts.'),
('Korea Motors TJ', '+992 90 411 22 33', 'Dushanbe, Sino district', 'Korean and Japanese imports.');
-- Parts ---------------------------------------------------------------------
INSERT INTO parts
(sku, name_en, name_tg, description_en, description_tg,
category_id, unit, cost_price, sale_price,
quantity_on_hand, reorder_level, location, barcode)
VALUES
-- Filters
('FLT-LDA-OIL-01', 'Oil filter Lada 2107', 'Филтри равған Lada 2107',
'Standard oil filter for VAZ 21012107 engines.',
'Филтри равғани муқаррарӣ барои ВАЗ 21012107.',
1, 'pcs', 2500, 4000, 24, 6, 'A1-01', '4607000000017'),
('FLT-DWO-OIL-01', 'Oil filter Daewoo Nexia', 'Филтри равған Daewoo Nexia',
'Oil filter for Daewoo Nexia 1.5 SOHC/DOHC.',
'Филтри равған барои Daewoo Nexia 1.5 SOHC/DOHC.',
1, 'pcs', 2800, 4500, 18, 6, 'A1-02', '4607000000024'),
('FLT-LDA-AIR-01', 'Air filter Lada Niva', 'Филтри ҳаво Lada Niva',
'Round-style air filter for Lada Niva 1.7i.',
'Филтри ҳавои гирд барои Lada Niva 1.7i.',
1, 'pcs', 3200, 5000, 12, 4, 'A1-03', '4607000000031'),
('FLT-OPL-AIR-01', 'Air filter Opel Astra H', 'Филтри ҳаво Opel Astra H',
'Panel air filter for Opel Astra H 1.6 / 1.8.',
'Филтри ҳавои панелӣ барои Opel Astra H 1.6 / 1.8.',
1, 'pcs', 4500, 7000, 9, 3, 'A1-04', '4607000000048'),
('FLT-TYT-FUL-01', 'Fuel filter Toyota Camry', 'Филтри сӯзишворӣ Toyota Camry',
'In-line fuel filter for Toyota Camry V30/V40.',
'Филтри сӯзишвории трубачагӣ барои Toyota Camry V30/V40.',
1, 'pcs', 6000, 9500, 7, 3, 'A1-05', '4607000000055'),
('FLT-DWO-CAB-01', 'Cabin filter Daewoo Nexia', 'Филтри салон Daewoo Nexia',
'Cabin / pollen filter for Daewoo Nexia.',
'Филтри салон / гардолуд барои Daewoo Nexia.',
1, 'pcs', 3500, 5500, 14, 4, 'A1-06', '4607000000062'),
-- Brakes
('BRK-LDA-PAD-F', 'Front brake pads Lada 2110', 'Колодкаҳои пеши тормоз Lada 2110',
'Front brake pad set for Lada 2110/2111/2112.',
'Маҷмӯи колодкаҳои пеши тормоз барои Lada 2110/2111/2112.',
2, 'set', 11000, 17000, 10, 3, 'B2-01', '4607000000079'),
('BRK-LDA-SHO-R', 'Rear brake shoes Lada Niva', 'Колодкаҳои қафои тормоз Lada Niva',
'Rear drum brake shoe set for Lada Niva.',
'Маҷмӯи колодкаҳои қафои барабаниро Lada Niva.',
2, 'set', 9500, 15000, 8, 3, 'B2-02', '4607000000086'),
('BRK-DWO-DSC-F', 'Front brake disc Daewoo Nexia', 'Диски тормози пеш Daewoo Nexia',
'Ventilated front brake disc, Daewoo Nexia.',
'Диски тормози пеши вентилятсияшаванда, Daewoo Nexia.',
2, 'pcs', 18000, 28000, 6, 2, 'B2-03', '4607000000093'),
('BRK-OPL-PAD-F', 'Front brake pads Opel Vectra B', 'Колодкаҳои пеши тормоз Opel Vectra B',
'Front brake pad set, Opel Vectra B 1.6 / 1.8.',
'Маҷмӯи колодкаҳои пеши тормоз, Opel Vectra B 1.6 / 1.8.',
2, 'set', 16000, 24000, 5, 2, 'B2-04', '4607000000109'),
('BRK-TYT-PAD-F', 'Front brake pads Toyota Corolla','Колодкаҳои пеши тормоз Toyota Corolla',
'Front brake pad set, Toyota Corolla E120/E150.',
'Маҷмӯи колодкаҳои пеши тормоз, Toyota Corolla E120/E150.',
2, 'set', 19000, 29500, 4, 2, 'B2-05', '4607000000116'),
-- Engine
('ENG-SPK-NGK-01', 'Spark plug NGK BPR6E', 'Шамъи оташфурӯзӣ NGK BPR6E',
'NGK BPR6E spark plug — common for VAZ.',
'Шамъи оташфурӯзии NGK BPR6E — барои ВАЗ.',
3, 'pcs', 1800, 3000, 60, 12, 'C3-01', '4607000000123'),
('ENG-SPK-DEN-01', 'Spark plug Denso K20TT', 'Шамъи оташфурӯзӣ Denso K20TT',
'Denso K20TT twin-tip plug, Toyota / Daewoo.',
'Шамъи Denso K20TT, Toyota / Daewoo.',
3, 'pcs', 3200, 5000, 36, 8, 'C3-02', '4607000000130'),
('ENG-LDA-PR-01', 'Piston ring set Lada 2106 STD', 'Маҷмӯи ҳалқаҳои поршен Lada 2106 STD',
'Standard-bore piston ring set for VAZ 2106.',
'Маҷмӯи ҳалқаҳои поршени андозаи стандартӣ барои ВАЗ 2106.',
3, 'set', 22000, 33000, 3, 1, 'C3-03', '4607000000147'),
('ENG-LDA-VCG-01', 'Valve cover gasket Lada 2110', 'Прокладкаи сарпӯши клапанҳо Lada 2110',
'Valve cover gasket for Lada 2110 16V.',
'Прокладкаи сарпӯши клапанҳо барои Lada 2110 16V.',
3, 'pcs', 3000, 4800, 11, 3, 'C3-04', '4607000000154'),
('ENG-DWO-MNT-01', 'Engine mount Daewoo Nexia', 'Подушкаи муҳаррик Daewoo Nexia',
'Right-side engine mount for Daewoo Nexia.',
'Подушкаи рости муҳаррик барои Daewoo Nexia.',
3, 'pcs', 14000, 22000, 4, 2, 'C3-05', '4607000000161'),
('ENG-OPL-THM-01', 'Thermostat Opel Astra H', 'Термостат Opel Astra H',
'Thermostat with housing, Opel Astra H 1.6.',
'Термостат бо корпус, Opel Astra H 1.6.',
3, 'pcs', 17000, 26000, 3, 1, 'C3-06', '4607000000178'),
('ENG-DWO-WPM-01', 'Water pump Daewoo Nexia', 'Насоси об Daewoo Nexia',
'Coolant water pump, Daewoo Nexia 1.5.',
'Насоси хунуккунӣ, Daewoo Nexia 1.5.',
3, 'pcs', 25000, 38000, 3, 1, 'C3-07', '4607000000185'),
-- Electrical
('ELC-LDA-STR-01', 'Starter motor Lada 2107', 'Стартери Lada 2107',
'Reduction-gear starter for Lada 2107 carb / inj.',
'Стартери редукторӣ барои Lada 2107.',
4, 'pcs', 65000, 95000, 2, 1, 'D4-01', '4607000000192'),
('ELC-DWO-ALT-01', 'Alternator 14V 80A Daewoo Nexia','Генератори 14V 80A Daewoo Nexia',
'14V 80A alternator, Daewoo Nexia 1.5.',
'Генератори 14V 80A, Daewoo Nexia 1.5.',
4, 'pcs', 95000,140000, 2, 1, 'D4-02', '4607000000208'),
('ELC-BAT-60AH-01','Car battery 60Ah 12V', 'Батареяи мошин 60Ач 12В',
'Maintenance-free 60Ah 12V battery, 500A CCA.',
'Батареяи бе нигоҳдории 60Ач 12В, 500А CCA.',
4, 'pcs', 55000, 78000, 6, 2, 'D4-03', '4607000000215'),
('ELC-H4-12V-01', 'Headlamp bulb H4 12V 60/55W', 'Лампаи фара H4 12V 60/55Вт',
'Halogen H4 headlamp bulb 12V 60/55W.',
'Лампаи галогении H4 барои фара 12V 60/55Вт.',
4, 'pcs', 1200, 2200, 40, 10, 'D4-04', '4607000000222'),
('ELC-TYT-COL-01', 'Ignition coil Toyota Corolla', 'Ғалтаки оташфурӯзӣ Toyota Corolla',
'Pencil-type ignition coil, Toyota Corolla 1.6 VVT-i.',
'Ғалтаки оташфурӯзии қаламшакл, Toyota Corolla 1.6 VVT-i.',
4, 'pcs', 28000, 42000, 4, 2, 'D4-05', '4607000000239'),
-- Fluids
('FLD-OIL-5W40-4L','Engine oil 5W-40 synthetic 4L', 'Равғани муҳаррик 5W-40 синтетикӣ 4Л',
'Fully synthetic 5W-40 motor oil, 4L jug.',
'Равғани муҳаррики пурра синтетикии 5W-40, 4 литр.',
5, 'btl', 14000, 21000, 15, 4, 'E5-01', '4607000000246'),
('FLD-OIL-10W40-4L','Engine oil 10W-40 semi-syn 4L', 'Равғани муҳаррик 10W-40 нимсинтетикӣ 4Л',
'Semi-synthetic 10W-40 motor oil, 4L jug.',
'Равғани муҳаррики нимсинтетикии 10W-40, 4 литр.',
5, 'btl', 9500, 15000, 22, 6, 'E5-02', '4607000000253'),
('FLD-BRK-DOT4-1L','Brake fluid DOT-4 1L', 'Моеъи тормоз DOT-4 1Л',
'DOT-4 brake fluid, 1L bottle.',
'Моеъи тормози DOT-4, шишаи 1 литр.',
5, 'btl', 2200, 3800, 20, 6, 'E5-03', '4607000000260'),
('FLD-AFR-G11-5L', 'Antifreeze G11 green 5L', 'Антифриз G11 сабз 5Л',
'G11 (green) antifreeze concentrate, 5L.',
'Антифризи G11 (сабз), консентрат, 5 литр.',
5, 'btl', 6800, 10500, 10, 3, 'E5-04', '4607000000277'),
-- Belts & Hoses
('BLT-DWO-TIM-01', 'Timing belt Daewoo Nexia 8V', 'Тасмаи газораспределение Daewoo Nexia 8V',
'Timing belt for Daewoo Nexia 1.5 SOHC (8V).',
'Тасмаи газораспределение барои Daewoo Nexia 1.5 SOHC (8V).',
6, 'pcs', 5500, 9000, 8, 3, 'F6-01', '4607000000284'),
('BLT-LDA-VBL-01', 'V-belt Lada 2107 alternator', 'Тасмаи V Lada 2107 (генератор)',
'V-belt for alternator, Lada 2107 carb engine.',
'Тасмаи V барои генератор, муҳаррики карбюратории Lada 2107.',
6, 'pcs', 1800, 3000, 20, 5, 'F6-02', '4607000000291'),
('HOS-LDA-RUP-01', 'Radiator upper hose Lada 2107', 'Шланги болоии радиатор Lada 2107',
'Upper radiator hose, Lada 2107.',
'Шланги болоии радиатор, Lada 2107.',
6, 'pcs', 2400, 3900, 12, 4, 'F6-03', '4607000000307'),
('HOS-DWO-RLW-01', 'Radiator lower hose Daewoo Nexia','Шланги поёнии радиатор Daewoo Nexia',
'Lower radiator hose, Daewoo Nexia.',
'Шланги поёнии радиатор, Daewoo Nexia.',
6, 'pcs', 3000, 4800, 9, 3, 'F6-04', '4607000000314');
-- A couple of opening "in" stock movements so the dashboard isn't empty.
INSERT INTO stock_movements (part_id, movement_type, quantity, unit_price, supplier_id, reference, notes)
SELECT id, 'in', quantity_on_hand, cost_price, 1, 'OPENING', 'Initial seeded stock'
FROM parts WHERE quantity_on_hand > 0;

View File

@ -0,0 +1,27 @@
import { getDb } from './db.js';
export function listSuppliers() {
return getDb().prepare(`SELECT * FROM suppliers ORDER BY name`).all();
}
export function getSupplier(id) {
return getDb().prepare(`SELECT * FROM suppliers WHERE id = ?`).get(id);
}
export function createSupplier(input) {
const stmt = getDb().prepare(`
INSERT INTO suppliers (name, phone, address, notes)
VALUES (@name, @phone, @address, @notes)
`);
const result = stmt.run({
name: (input.name || '').trim(),
phone: input.phone?.trim() || null,
address: input.address?.trim() || null,
notes: input.notes?.trim() || null
});
return result.lastInsertRowid;
}
export function deleteSupplier(id) {
getDb().prepare(`DELETE FROM suppliers WHERE id = ?`).run(id);
}

108
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,108 @@
<script>
import Header from '$lib/components/Header.svelte';
</script>
<Header />
<main class="container">
<slot />
</main>
<style>
:global(*, *::before, *::after) { box-sizing: border-box; }
:global(html, body) { margin: 0; padding: 0; }
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
color: #1d2330;
background: #fbf7f1; /* warm cream — a slightly Central-Asian palette */
}
:global(a) { color: #006a4e; }
:global(a:hover) { color: #00553e; }
:global(h1) { font-size: 1.6rem; margin: 0 0 1rem; }
:global(h2) { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
:global(table) {
width: 100%;
border-collapse: collapse;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
:global(th, td) {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid #e5e8ee;
font-size: 0.92rem;
}
:global(th) {
background: #eef1f6;
font-weight: 600;
white-space: nowrap;
}
:global(tr:hover td) { background: #fafbfd; }
:global(td.num, th.num) { text-align: right; font-variant-numeric: tabular-nums; }
:global(form.stack) { display: grid; gap: 0.9rem; max-width: 640px; }
:global(form.stack label) { display: grid; gap: 0.25rem; font-size: 0.9rem; }
:global(form.stack .row) { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
:global(input, select, textarea) {
font: inherit;
padding: 0.5rem 0.6rem;
border: 1px solid #c8cfdc;
border-radius: 4px;
background: #fff;
color: inherit;
}
:global(textarea) { min-height: 4.5rem; }
:global(button) {
font: inherit;
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
background: #006a4e;
color: #fff;
}
:global(button:hover) { background: #00553e; }
:global(button.secondary) {
background: #fff;
color: #1d2330;
border-color: #c8cfdc;
}
:global(button.secondary:hover) { background: #f0f2f6; }
:global(button.danger) {
background: #c8102e;
}
:global(button.danger:hover) { background: #a30d24; }
:global(button:disabled) { opacity: 0.6; cursor: not-allowed; }
:global(.card) {
background: #fff;
border-radius: 6px;
padding: 1rem 1.25rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
:global(.error) {
background: #fdecea;
border: 1px solid #f5c2c0;
color: #8a1f1b;
padding: 0.6rem 0.85rem;
border-radius: 4px;
margin-bottom: 1rem;
}
:global(.muted) { color: #6b7388; }
:global(.pill) {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
background: #eef1f6;
font-size: 0.8rem;
}
:global(.pill.low) { background: #fde6e4; color: #8a1f1b; }
.container {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
</style>

View File

@ -0,0 +1,10 @@
import { dashboardStats, lowStockParts } from '$lib/server/parts.js';
import { recentMovements } from '$lib/server/movements.js';
export function load() {
return {
stats: dashboardStats(),
lowStock: lowStockParts(10),
movements: recentMovements(10)
};
}

109
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,109 @@
<script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
export let data;
$: lang = $locale;
$: ({ stats, lowStock, movements } = data);
</script>
<h1>{$t('dashboard.title')}</h1>
<div class="grid">
<div class="card stat">
<div class="label">{$t('dashboard.total_skus')}</div>
<div class="value">{stats.total}</div>
</div>
<div class="card stat">
<div class="label">{$t('dashboard.low_stock')}</div>
<div class="value" class:warn={stats.lowStock > 0}>{stats.lowStock}</div>
</div>
<div class="card stat">
<div class="label">{$t('dashboard.inventory_value')}</div>
<div class="value">
{formatMoney(stats.inventoryValueDirams, lang)}
<span class="cur">{$t('common.currency_short')}</span>
</div>
</div>
</div>
<div class="card quick">
<strong>{$t('dashboard.quick_actions')}</strong>
<a href="/parts/new">{$t('nav.new_part')}</a>
<a href="/movements/new">{$t('nav.new_movement')}</a>
<a href="/parts">{$t('nav.parts')}</a>
</div>
<h2>{$t('dashboard.low_stock_list')}</h2>
{#if lowStock.length === 0}
<p class="muted">{$t('common.none')}</p>
{:else}
<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>
</tr>
</thead>
<tbody>
{#each lowStock as p}
<tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td class="num"><span class="pill low">{p.quantity_on_hand}</span></td>
<td class="num">{p.reorder_level}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<h2>{$t('dashboard.recent_movements')}</h2>
{#if movements.length === 0}
<p class="muted">{$t('movements.no_movements')}</p>
{:else}
<table>
<thead>
<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>
</thead>
<tbody>
{#each movements as m}
<tr>
<td>{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 class="num">{m.quantity}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat .label { color: #6b7388; font-size: 0.85rem; }
.stat .value {
font-size: 1.8rem;
font-weight: 600;
margin-top: 0.25rem;
font-variant-numeric: tabular-nums;
}
.stat .value.warn { color: #b8443f; }
.stat .cur { font-size: 0.85rem; color: #6b7388; margin-left: 0.25rem; }
.quick { display: flex; align-items: center; gap: 1rem; margin: 1rem 0; }
.quick strong { margin-right: auto; }
</style>

View File

@ -0,0 +1,39 @@
import { fail, redirect } from '@sveltejs/kit';
import { listParts } from '$lib/server/parts.js';
import { listSuppliers } from '$lib/server/suppliers.js';
import { recordMovement } from '$lib/server/movements.js';
export function load({ url }) {
return {
parts: listParts(),
suppliers: listSuppliers(),
presetPartId: url.searchParams.get('part_id') || ''
};
}
export const actions = {
default: async ({ request }) => {
const form = await request.formData();
const data = Object.fromEntries(form);
const errors = {};
if (!data.part_id) errors.part_id = 'movements.errors.part_required';
const qty = Number(data.quantity);
if (!Number.isInteger(qty) || qty < 0) errors.quantity = 'movements.errors.quantity_required';
// 'in' or 'adjust' may legitimately use 0 (e.g. adjust to zero), so we
// only block negative or non-integer; 'out' requires > 0.
if (data.movement_type === 'out' && qty <= 0) errors.quantity = 'movements.errors.quantity_required';
if (Object.keys(errors).length) return fail(400, { errors, values: data });
try {
recordMovement(data);
} catch (err) {
if (String(err.message).includes('not enough stock')) {
return fail(400, { errors: { quantity: 'movements.errors.not_enough_stock' }, values: data });
}
throw err;
}
throw redirect(303, `/parts/${data.part_id}`);
}
};

View File

@ -0,0 +1,168 @@
<script>
import { locale, t, localized } from '$lib/i18n/store.js';
export let data;
export let form;
$: lang = $locale;
$: ({ parts, suppliers, presetPartId } = data);
$: errors = form?.errors ?? {};
$: values = form?.values ?? {};
// Reference data/form directly here — the reactive `$: values = ...` above
// hasn't run yet at component init.
let movementType = form?.values?.movement_type ?? 'in';
let partId = String(form?.values?.part_id ?? data?.presetPartId ?? '');
let partSearch = '';
$: filteredParts = (() => {
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)
);
})();
// If the user filters away the currently selected part, still keep it
// visible in the dropdown so they don't lose context.
$: selectedPart = parts?.find((p) => String(p.id) === String(partId));
$: visibleParts =
selectedPart && !filteredParts.some((p) => p.id === selectedPart.id)
? [selectedPart, ...filteredParts]
: filteredParts;
// Unit price: auto-filled from the chosen part — cost for 'in', sale for
// 'out'. We track the last auto-filled value; if the user has typed
// something different, we leave their input alone on subsequent
// part/type changes.
let unitPrice = form?.values?.unit_price ?? '';
let lastAutoUnitPrice = '';
function priceFor(part, type) {
if (!part || type === 'adjust') return '';
const dirams = type === 'in' ? part.cost_price : part.sale_price;
if (!dirams || dirams <= 0) return '';
return (dirams / 100).toFixed(2);
}
$: {
const expected = priceFor(selectedPart, movementType);
if (expected && (unitPrice === '' || unitPrice === lastAutoUnitPrice)) {
unitPrice = expected;
lastAutoUnitPrice = expected;
}
}
// Quantity: for 'adjust' we pre-fill with the part's current on-hand so
// the user can edit to the new total. Same don't-clobber-manual-edits
// rule as unit price.
let quantity = form?.values?.quantity ?? '';
let lastAutoQuantity = '';
$: {
if (movementType === 'adjust' && selectedPart) {
const expected = String(selectedPart.quantity_on_hand);
if (quantity === '' || quantity === lastAutoQuantity) {
quantity = expected;
lastAutoQuantity = expected;
}
}
}
</script>
<h1>{$t('movements.new')}</h1>
<form class="stack" method="POST">
<label>
{$t('movements.type')}
<select name="movement_type" bind:value={movementType}>
<option value="in">{$t('movements.type_in')}</option>
<option value="out">{$t('movements.type_out')}</option>
<option value="adjust">{$t('movements.type_adjust')}</option>
</select>
</label>
<label>
{$t('movements.part')} *
<input class="part-search"
type="search"
bind:value={partSearch}
placeholder={$t('parts.search_placeholder')} />
<select name="part_id" bind:value={partId} required size={Math.min(8, Math.max(3, visibleParts.length + 1))}>
<option value=""></option>
{#each visibleParts as p}
<option value={p.id}>
{p.sku}{localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
</option>
{/each}
</select>
{#if partSearch && filteredParts.length === 0}
<small class="muted">{$t('parts.no_results')}</small>
{/if}
{#if errors.part_id}<span class="field-error">{$t(errors.part_id)}</span>{/if}
</label>
<label>
{$t('movements.quantity')} *
<input name="quantity" type="number" min="0" step="1" required
bind:value={quantity} />
{#if errors.quantity}<span class="field-error">{$t(errors.quantity)}</span>{/if}
{#if movementType === 'adjust' && selectedPart}
<small class="muted">
{$t('parts.quantity_on_hand')}: {selectedPart.quantity_on_hand}
</small>
{/if}
</label>
{#if movementType !== 'adjust'}
<label>
{$t('movements.unit_price')} ({$t('common.currency_short')})
<input name="unit_price" type="number" step="0.01" min="0"
bind:value={unitPrice} />
{#if selectedPart && unitPrice === lastAutoUnitPrice && unitPrice !== ''}
<small class="muted">
{movementType === 'in' ? $t('parts.cost_price') : $t('parts.sale_price')}
</small>
{/if}
</label>
{/if}
{#if movementType === 'in'}
<label>
{$t('movements.supplier')}
<select name="supplier_id">
<option value=""></option>
{#each suppliers as s}
<option value={s.id} selected={String(values.supplier_id) === String(s.id)}>
{s.name}
</option>
{/each}
</select>
</label>
{/if}
<div class="row">
<label>
{$t('movements.reference')}
<input name="reference" value={values.reference ?? ''} />
</label>
<label>
{$t('movements.notes')}
<input name="notes" value={values.notes ?? ''} />
</label>
</div>
<div class="actions">
<button type="submit">{$t('common.save')}</button>
<a href="/parts" class="muted">{$t('common.cancel')}</a>
</div>
</form>
<style>
.actions { display: flex; gap: 1rem; align-items: center; }
.field-error { color: #8a1f1b; font-size: 0.8rem; }
.part-search { margin-bottom: 0.35rem; }
</style>

View File

@ -0,0 +1,8 @@
import { listParts } 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 };
}

View File

@ -0,0 +1,126 @@
<script>
import { goto } from '$app/navigation';
import { locale, t, localized, formatMoney, hasTranslation } from '$lib/i18n/store.js';
export let data;
$: lang = $locale;
$: ({ parts, q, sort, dir } = data);
let search = data.q;
function applySearch(e) {
e?.preventDefault?.();
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() : ''));
}
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());
}
function arrow(col) {
if (sort !== col) return '';
return dir === 'asc' ? '▲' : '▼';
}
</script>
<div class="page-head">
<h1>{$t('parts.title')}</h1>
<a class="add-btn" href="/parts/new">+ {$t('nav.new_part')}</a>
</div>
<form class="search" on:submit={applySearch}>
<input type="search"
bind:value={search}
placeholder={$t('parts.search_placeholder')} />
<button type="submit">{$t('common.search')}</button>
{#if search}
<button type="button" class="secondary"
on:click={() => { search = ''; applySearch(); }}>
{$t('common.clear')}
</button>
{/if}
</form>
{#if parts.length === 0}
<p class="muted card">{$t('parts.no_results')}</p>
{:else}
<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>
<th class="num"><button class="th-btn" on:click={() => sortBy('reorder_level')}>{$t('parts.reorder_level')} {arrow('reorder_level')}</button></th>
<th class="num"><button class="th-btn" on:click={() => sortBy('sale_price')}>{$t('parts.sale_price')} {arrow('sale_price')}</button></th>
</tr>
</thead>
<tbody>
{#each parts as p}
<tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td>
{localized(p, 'name', lang)}
{#if !hasTranslation(p, 'name', lang)}
<em class="missing">{$t('common.missing_translation')}</em>
{/if}
</td>
<td>{localized({name_en: p.category_name_en, name_tg: p.category_name_tg}, 'name', lang) || $t('common.none')}</td>
<td class="num">
{#if p.quantity_on_hand <= p.reorder_level}
<span class="pill low">{p.quantity_on_hand}</span>
{:else}
{p.quantity_on_hand}
{/if}
</td>
<td class="num">{p.reorder_level}</td>
<td class="num">{formatMoney(p.sale_price, lang)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<style>
.page-head { display: flex; align-items: center; justify-content: space-between; }
.add-btn {
background: #006a4e;
color: #fff;
padding: 0.5rem 0.9rem;
border-radius: 4px;
text-decoration: none;
}
.add-btn:hover { background: #00553e; color: #fff; }
.search {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0 1rem;
}
.search input { flex: 1; }
.th-btn {
background: transparent;
color: inherit;
border: none;
padding: 0;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.th-btn:hover { color: #006a4e; background: transparent; }
.missing {
color: #8a6f1b;
font-size: 0.8em;
margin-left: 0.35rem;
font-style: italic;
}
</style>

View File

@ -0,0 +1,34 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { getPart, getPartBySku, listCategories, updatePart } from '$lib/server/parts.js';
import { recentMovementsForPart } from '$lib/server/movements.js';
export function load({ params }) {
const id = Number(params.id);
const part = getPart(id);
if (!part) throw error(404, 'Part not found');
return {
part,
categories: listCategories(),
movements: recentMovementsForPart(id, 25)
};
}
export const actions = {
default: 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}`);
}
};

View File

@ -0,0 +1,195 @@
<script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
export let data;
export let form;
$: lang = $locale;
$: ({ part, categories, movements } = data);
$: errors = form?.errors ?? {};
$: values = form?.values ?? {};
// Display somoni (not dirams) in the form.
function asSomoni(dirams) {
if (dirams == null) return '';
return (Number(dirams) / 100).toFixed(2);
}
</script>
<div class="page-head">
<h1>{$t('parts.edit')}: {part.sku}</h1>
<a href="/parts" class="muted">{$t('common.back')}</a>
</div>
{#if errors.name}
<div class="error">{$t(errors.name)}</div>
{/if}
<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>
<div class="row">
<label>
{$t('parts.name_en')}
<input name="name_en" value={values.name_en ?? part.name_en} />
</label>
<label>
{$t('parts.name_tg')}
<input name="name_tg" value={values.name_tg ?? part.name_tg} />
</label>
</div>
<label>
{$t('parts.category')}
<select name="category_id">
<option value=""></option>
{#each categories as c}
<option value={c.id}
selected={String(values.category_id ?? part.category_id) === String(c.id)}>
{localized(c, 'name', lang)}
</option>
{/each}
</select>
</label>
<div class="row">
<label>
{$t('parts.cost_price')} ({$t('common.currency_short')})
<input name="cost_price" type="number" step="0.01" min="0"
value={values.cost_price ?? asSomoni(part.cost_price)} />
</label>
<label>
{$t('parts.sale_price')} ({$t('common.currency_short')})
<input name="sale_price" type="number" step="0.01" min="0"
value={values.sale_price ?? asSomoni(part.sale_price)} />
</label>
</div>
<div class="row">
<label>
{$t('parts.unit')}
<input name="unit" value={values.unit ?? part.unit} />
</label>
<label>
{$t('parts.reorder_level')}
<input name="reorder_level" type="number" min="0" step="1"
value={values.reorder_level ?? part.reorder_level} />
</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"
checked={values.active != null ? values.active === '1' : !!part.active} />
{$t('parts.active')}
</label>
<div class="actions">
<button type="submit">{$t('common.save')}</button>
<a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a>
</div>
</form>
</section>
<aside>
<div class="card">
<div class="muted">{$t('parts.quantity_on_hand')}</div>
<div class="qty" class:low={part.quantity_on_hand <= part.reorder_level}>
{part.quantity_on_hand}
<span class="unit">{part.unit}</span>
</div>
<div class="muted small">
{$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>
<h2>{$t('parts.recent_movements')}</h2>
{#if movements.length === 0}
<p class="muted">{$t('movements.no_movements')}</p>
{:else}
<table>
<thead>
<tr>
<th>{$t('movements.created_at')}</th>
<th>{$t('movements.type')}</th>
<th class="num">{$t('movements.quantity')}</th>
<th class="num">{$t('movements.unit_price')}</th>
</tr>
</thead>
<tbody>
{#each movements as m}
<tr>
<td>{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>
</tr>
{/each}
</tbody>
</table>
{/if}
</aside>
</div>
<style>
.page-head { display: flex; justify-content: space-between; align-items: baseline; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 880px) {
.layout { grid-template-columns: 1fr; }
}
.actions { display: flex; gap: 1rem; align-items: center; }
.btn-link {
margin-left: auto; /* push to the right edge of the form */
background: #006a4e;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
border: 1px solid transparent;
font: inherit;
}
.btn-link:hover { background: #00553e; color: #fff; }
.checkbox { display: flex; align-items: center; gap: 0.4rem; }
.field-error { color: #8a1f1b; font-size: 0.8rem; }
.qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; }
.qty.low { color: #b8443f; }
.qty .unit { font-size: 0.9rem; color: #6b7388; font-weight: 400; }
.small { font-size: 0.8rem; }
hr { border: 0; border-top: 1px solid #eef0f5; margin: 0.6rem 0; }
</style>

View File

@ -0,0 +1,46 @@
import { fail, redirect } from '@sveltejs/kit';
import { createPart, getPartBySku, listCategories } from '$lib/server/parts.js';
import { recordMovement } from '$lib/server/movements.js';
export function load() {
return { categories: listCategories() };
}
export const actions = {
default: async ({ request }) => {
const form = await request.formData();
const data = Object.fromEntries(form);
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.
const initialQty = Number(data.quantity_on_hand || 0);
const id = createPart({ ...data, quantity_on_hand: 0 });
if (initialQty > 0) {
recordMovement({
part_id: id,
movement_type: 'in',
quantity: initialQty,
unit_price: data.cost_price,
reference: 'OPENING'
});
}
throw redirect(303, `/parts/${id}`);
}
};
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';
}
return Object.keys(errors).length ? errors : null;
}

View File

@ -0,0 +1,108 @@
<script>
import { locale, t, localized } from '$lib/i18n/store.js';
export let data;
export let form;
$: lang = $locale;
$: ({ categories } = data);
$: errors = form?.errors ?? {};
$: values = form?.values ?? {};
</script>
<h1>{$t('parts.new')}</h1>
{#if errors.name}
<div class="error">{$t(errors.name)}</div>
{/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')}
<input name="name_en" value={values.name_en ?? ''} />
</label>
<label>
{$t('parts.name_tg')}
<input name="name_tg" value={values.name_tg ?? ''} />
</label>
</div>
<label>
{$t('parts.category')}
<select name="category_id">
<option value=""></option>
{#each categories as c}
<option value={c.id} selected={String(values.category_id) === String(c.id)}>
{localized(c, 'name', lang)}
</option>
{/each}
</select>
</label>
<div class="row">
<label>
{$t('parts.cost_price')} ({$t('common.currency_short')})
<input name="cost_price" type="number" step="0.01" min="0" value={values.cost_price ?? ''} />
</label>
<label>
{$t('parts.sale_price')} ({$t('common.currency_short')})
<input name="sale_price" type="number" step="0.01" min="0" value={values.sale_price ?? ''} />
</label>
</div>
<div class="row">
<label>
{$t('parts.unit')}
<input name="unit" value={values.unit ?? 'pcs'} />
</label>
<label>
{$t('parts.reorder_level')}
<input name="reorder_level" type="number" min="0" step="1" value={values.reorder_level ?? 0} />
</label>
</div>
<div class="row">
<label>
{$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">
<button type="submit">{$t('common.save')}</button>
<a href="/parts" class="cancel">{$t('common.cancel')}</a>
</div>
</form>
<style>
.actions { display: flex; gap: 1rem; align-items: center; }
.cancel { color: #6b7388; text-decoration: none; }
.field-error { color: #8a1f1b; font-size: 0.8rem; }
</style>

View File

@ -0,0 +1,24 @@
import { fail } from '@sveltejs/kit';
import { listSuppliers, createSupplier, deleteSupplier } from '$lib/server/suppliers.js';
export function load() {
return { suppliers: listSuppliers() };
}
export const actions = {
create: async ({ request }) => {
const form = await request.formData();
const data = Object.fromEntries(form);
if (!data.name || !data.name.trim()) {
return fail(400, { errors: { name: 'suppliers.errors.name_required' }, values: data });
}
createSupplier(data);
return { ok: true };
},
delete: async ({ request }) => {
const form = await request.formData();
const id = Number(form.get('id'));
if (id) deleteSupplier(id);
return { ok: true };
}
};

View File

@ -0,0 +1,75 @@
<script>
import { t } from '$lib/i18n/store.js';
import { enhance } from '$app/forms';
export let data;
export let form;
$: ({ suppliers } = data);
$: errors = form?.errors ?? {};
$: values = form?.values ?? {};
</script>
<h1>{$t('suppliers.title')}</h1>
{#if suppliers.length === 0}
<p class="muted">{$t('suppliers.no_suppliers')}</p>
{:else}
<table>
<thead>
<tr>
<th>{$t('suppliers.name')}</th>
<th>{$t('suppliers.phone')}</th>
<th>{$t('suppliers.address')}</th>
<th>{$t('suppliers.notes')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each suppliers as s}
<tr>
<td><strong>{s.name}</strong></td>
<td>{s.phone || $t('common.none')}</td>
<td>{s.address || $t('common.none')}</td>
<td>{s.notes || $t('common.none')}</td>
<td>
<form method="POST" action="?/delete" use:enhance
on:submit={(e) => { if (!confirm($t('suppliers.delete_confirm'))) e.preventDefault(); }}>
<input type="hidden" name="id" value={s.id} />
<button class="danger" type="submit">{$t('common.delete')}</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<h2>{$t('suppliers.add')}</h2>
<form class="stack" method="POST" action="?/create" use:enhance>
<label>
{$t('suppliers.name')} *
<input name="name" required value={values.name ?? ''} />
{#if errors.name}<span class="field-error">{$t(errors.name)}</span>{/if}
</label>
<div class="row">
<label>
{$t('suppliers.phone')}
<input name="phone" value={values.phone ?? ''} />
</label>
<label>
{$t('suppliers.address')}
<input name="address" value={values.address ?? ''} />
</label>
</div>
<label>
{$t('suppliers.notes')}
<textarea name="notes">{values.notes ?? ''}</textarea>
</label>
<div>
<button type="submit">{$t('common.add')}</button>
</div>
</form>
<style>
.field-error { color: #8a1f1b; font-size: 0.8rem; }
</style>