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

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>