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:
168
src/routes/movements/new/+page.svelte
Normal file
168
src/routes/movements/new/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user