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>
214 lines
6.6 KiB
Svelte
214 lines
6.6 KiB
Svelte
<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.
|
|
// When arriving with ?part_id=… (from a part detail page), default to an
|
|
// 'out' movement of 1 — the common case is recording a sale.
|
|
let movementType = form?.values?.movement_type ?? (data?.presetPartId ? 'out' : '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.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.
|
|
// When arriving from a part detail page, start at 1 (the typical sale).
|
|
let quantity = form?.values?.quantity ?? (data?.presetPartId ? '1' : '');
|
|
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">
|
|
<fieldset class="type-group">
|
|
<legend>{$t('movements.type')}</legend>
|
|
<input type="hidden" name="movement_type" value={movementType} />
|
|
<div class="type-buttons" role="radiogroup">
|
|
{#each ['in', 'out', 'adjust'] as opt}
|
|
<button type="button"
|
|
class="type-btn"
|
|
class:active={movementType === opt}
|
|
aria-pressed={movementType === opt}
|
|
on:click={() => (movementType = opt)}>
|
|
{$t('movements.type_' + opt)}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<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={String(p.id)}>
|
|
{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; }
|
|
|
|
.type-group {
|
|
border: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.type-group legend {
|
|
padding: 0;
|
|
margin-bottom: 0.35rem;
|
|
font-weight: 500;
|
|
}
|
|
.type-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.type-btn {
|
|
flex: 1 1 0;
|
|
min-width: 7rem;
|
|
padding: 0.85rem 1rem;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
background: #f3f4f7;
|
|
color: #2a2f3a;
|
|
border: 2px solid #d8dbe3;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
|
}
|
|
.type-btn:hover { background: #e7eaf0; }
|
|
.type-btn.active {
|
|
background: #006a4e;
|
|
border-color: #006a4e;
|
|
color: #fff;
|
|
}
|
|
.type-btn.active:hover { background: #00553e; }
|
|
</style>
|