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

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