Add multi-line sale builder with pending-draft safeguard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,7 @@
|
||||
<nav class="nav">
|
||||
<a href="/">{$t('nav.dashboard')}</a>
|
||||
<a href="/parts">{$t('nav.parts')}</a>
|
||||
<a href="/invoices/new">{$t('nav.new_sale')}</a>
|
||||
<a href="/movements/new">{$t('nav.movements')}</a>
|
||||
<a href="/suppliers">{$t('nav.suppliers')}</a>
|
||||
<a href="/admin">{$t('nav.admin')}</a>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"parts": "Parts",
|
||||
"new_sale": "New sale",
|
||||
"movements": "Movements",
|
||||
"suppliers": "Suppliers",
|
||||
"admin": "Backups",
|
||||
@ -119,6 +120,37 @@
|
||||
"restore_failed": "Restore failed. See the server logs."
|
||||
}
|
||||
},
|
||||
"invoices": {
|
||||
"title": "New sale",
|
||||
"saved_title": "Invoice",
|
||||
"add_part": "Add a part",
|
||||
"add_custom": "Add custom line",
|
||||
"custom_label": "Description",
|
||||
"custom_placeholder": "e.g. Labor, delivery, discount",
|
||||
"item": "Item",
|
||||
"no_inventory_impact": "Custom line — no inventory change",
|
||||
"lines": "Lines on this invoice",
|
||||
"no_lines": "No lines yet — add a part or a custom line to get started.",
|
||||
"line_total": "Line total",
|
||||
"running_total": "Running total",
|
||||
"save": "Save Invoice",
|
||||
"cancel": "Cancel invoice",
|
||||
"cancel_confirm": "Permanently discard this draft? All added lines will be lost.",
|
||||
"saved_total": "Total",
|
||||
"saved_thanks": "Scan the QR code below to pay.",
|
||||
"new_another": "Start a new sale",
|
||||
"errors": {
|
||||
"part_required": "Pick a part.",
|
||||
"quantity_required": "Quantity must be a positive whole number.",
|
||||
"label_required": "Description is required for custom lines.",
|
||||
"not_enough_stock": "One or more lines exceed available stock. Adjust quantities and try again.",
|
||||
"empty": "Add at least one line before saving.",
|
||||
"save_failed": "Could not save the invoice. Please try again.",
|
||||
"add_failed": "Could not add this line.",
|
||||
"update_failed": "Could not update this line.",
|
||||
"line_missing": "Line not found."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Suppliers",
|
||||
"name": "Name",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"nav": {
|
||||
"dashboard": "Лавҳаи асосӣ",
|
||||
"parts": "Қисмҳо",
|
||||
"new_sale": "Фурӯши нав",
|
||||
"movements": "Ҳаракатҳо",
|
||||
"suppliers": "Таъминкунандагон",
|
||||
"admin": "Нусхаҳо",
|
||||
@ -119,6 +120,37 @@
|
||||
"restore_failed": "Барқарорсозӣ ноком шуд. Логи серверро бинед."
|
||||
}
|
||||
},
|
||||
"invoices": {
|
||||
"title": "Фурӯши нав",
|
||||
"saved_title": "Фактура",
|
||||
"add_part": "Илова кардани қисм",
|
||||
"add_custom": "Илова кардани сатри иловагӣ",
|
||||
"custom_label": "Тавсиф",
|
||||
"custom_placeholder": "масалан: кор, расондан, тахфиф",
|
||||
"item": "Маҳсулот",
|
||||
"no_inventory_impact": "Сатри иловагӣ — ба захира таъсир намерасонад",
|
||||
"lines": "Сатрҳои ин фактура",
|
||||
"no_lines": "Ҳоло ягон сатр нест — як қисм ё сатри иловагӣ илова кунед.",
|
||||
"line_total": "Маблағи сатр",
|
||||
"running_total": "Ҳамагӣ ҷорӣ",
|
||||
"save": "Захира кардани фактура",
|
||||
"cancel": "Бекор кардани фактура",
|
||||
"cancel_confirm": "Ин лоиҳаро пурра нест мекунед? Ҳамаи сатрҳои иловашуда гум мешаванд.",
|
||||
"saved_total": "Ҳамагӣ",
|
||||
"saved_thanks": "Барои пардохт рамзи QR-ро аз поён скан кунед.",
|
||||
"new_another": "Фурӯши нав сар кардан",
|
||||
"errors": {
|
||||
"part_required": "Қисмро интихоб кунед.",
|
||||
"quantity_required": "Миқдор бояд бутун ва мусбат бошад.",
|
||||
"label_required": "Барои сатри иловагӣ тавсиф зарур аст.",
|
||||
"not_enough_stock": "Як ё якчанд сатр аз захираи мавҷуда зиёд аст. Миқдорро тағйир дода, аз нав кӯшиш кунед.",
|
||||
"empty": "Пеш аз захира кардан камаш як сатр илова кунед.",
|
||||
"save_failed": "Захира кардани фактура муяссар нашуд. Аз нав кӯшиш кунед.",
|
||||
"add_failed": "Илова кардани ин сатр муяссар нашуд.",
|
||||
"update_failed": "Тағйир додани ин сатр муяссар нашуд.",
|
||||
"line_missing": "Сатр ёфт нашуд."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Таъминкунандагон",
|
||||
"name": "Ном",
|
||||
|
||||
194
src/lib/server/invoices.js
Normal file
194
src/lib/server/invoices.js
Normal file
@ -0,0 +1,194 @@
|
||||
import { getDb } from './db.js';
|
||||
import { recordMovement } from './movements.js';
|
||||
|
||||
export function getPendingInvoice() {
|
||||
const db = getDb();
|
||||
const invoice = db.prepare(`
|
||||
SELECT * FROM invoices WHERE status = 'pending' LIMIT 1
|
||||
`).get();
|
||||
if (!invoice) return null;
|
||||
return { invoice, lines: linesFor(invoice.id) };
|
||||
}
|
||||
|
||||
export function getOrCreatePendingInvoice() {
|
||||
const db = getDb();
|
||||
const existing = db.prepare(`SELECT * FROM invoices WHERE status = 'pending' LIMIT 1`).get();
|
||||
if (existing) return { invoice: existing, lines: linesFor(existing.id) };
|
||||
const id = db.prepare(`INSERT INTO invoices (status) VALUES ('pending')`).run().lastInsertRowid;
|
||||
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(id);
|
||||
return { invoice, lines: [] };
|
||||
}
|
||||
|
||||
export function getInvoice(id) {
|
||||
const db = getDb();
|
||||
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(Number(id));
|
||||
if (!invoice) return null;
|
||||
return { invoice, lines: linesFor(invoice.id) };
|
||||
}
|
||||
|
||||
function linesFor(invoiceId) {
|
||||
return getDb().prepare(`
|
||||
SELECT
|
||||
l.*,
|
||||
p.sku AS part_sku,
|
||||
p.name_en AS part_name_en,
|
||||
p.name_tg AS part_name_tg,
|
||||
p.quantity_on_hand AS part_on_hand
|
||||
FROM invoice_lines l
|
||||
LEFT JOIN parts p ON p.id = l.part_id
|
||||
WHERE l.invoice_id = ?
|
||||
ORDER BY l.sort_order, l.id
|
||||
`).all(invoiceId);
|
||||
}
|
||||
|
||||
function nextSortOrder(invoiceId) {
|
||||
const row = getDb().prepare(`
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM invoice_lines WHERE invoice_id = ?
|
||||
`).get(invoiceId);
|
||||
return row.n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a parts-based line to an invoice. If a line for the same part_id
|
||||
* already exists on this invoice, increments that line's quantity rather
|
||||
* than inserting a new row (per-edit merge). The line's unit price is left
|
||||
* alone in the merge case so a user-edited price isn't clobbered.
|
||||
*/
|
||||
export function addLine({ invoice_id, part_id, quantity, unit_price_dirams }) {
|
||||
const db = getDb();
|
||||
const invoiceId = Number(invoice_id);
|
||||
const partId = Number(part_id);
|
||||
const qty = Math.floor(Number(quantity));
|
||||
if (!invoiceId) throw new Error('invoice_id required');
|
||||
if (!partId) throw new Error('part_id required');
|
||||
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
|
||||
|
||||
return db.transaction(() => {
|
||||
const part = db.prepare(`SELECT id, sale_price FROM parts WHERE id = ?`).get(partId);
|
||||
if (!part) throw new Error(`part ${partId} not found`);
|
||||
|
||||
const price = (unit_price_dirams === '' || unit_price_dirams == null)
|
||||
? Number(part.sale_price || 0)
|
||||
: Math.round(Number(unit_price_dirams));
|
||||
|
||||
const existing = db.prepare(`
|
||||
SELECT id, quantity FROM invoice_lines
|
||||
WHERE invoice_id = ? AND part_id = ? AND affects_inventory = 1
|
||||
LIMIT 1
|
||||
`).get(invoiceId, partId);
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE invoice_lines SET quantity = ? WHERE id = ?`)
|
||||
.run(existing.quantity + qty, existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
INSERT INTO invoice_lines
|
||||
(invoice_id, part_id, label, quantity, unit_price_dirams, affects_inventory, sort_order)
|
||||
VALUES (?, ?, NULL, ?, ?, 1, ?)
|
||||
`).run(invoiceId, partId, qty, price, nextSortOrder(invoiceId)).lastInsertRowid;
|
||||
})();
|
||||
}
|
||||
|
||||
export function addCustomLine({ invoice_id, label, quantity, unit_price_dirams }) {
|
||||
const db = getDb();
|
||||
const invoiceId = Number(invoice_id);
|
||||
const cleanLabel = (label || '').trim();
|
||||
const qty = Math.floor(Number(quantity));
|
||||
const price = Math.round(Number(unit_price_dirams || 0));
|
||||
if (!invoiceId) throw new Error('invoice_id required');
|
||||
if (!cleanLabel) throw new Error('label required');
|
||||
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
|
||||
|
||||
return db.prepare(`
|
||||
INSERT INTO invoice_lines
|
||||
(invoice_id, part_id, label, quantity, unit_price_dirams, affects_inventory, sort_order)
|
||||
VALUES (?, NULL, ?, ?, ?, 0, ?)
|
||||
`).run(invoiceId, cleanLabel, qty, price, nextSortOrder(invoiceId)).lastInsertRowid;
|
||||
}
|
||||
|
||||
export function updateLine(line_id, { quantity, unit_price_dirams, label }) {
|
||||
const db = getDb();
|
||||
const id = Number(line_id);
|
||||
if (!id) throw new Error('line_id required');
|
||||
|
||||
const current = db.prepare(`SELECT * FROM invoice_lines WHERE id = ?`).get(id);
|
||||
if (!current) throw new Error(`line ${id} not found`);
|
||||
|
||||
const qty = quantity == null || quantity === ''
|
||||
? current.quantity
|
||||
: Math.floor(Number(quantity));
|
||||
if (!Number.isInteger(qty) || qty <= 0) throw new Error('quantity must be a positive integer');
|
||||
|
||||
const price = unit_price_dirams == null || unit_price_dirams === ''
|
||||
? current.unit_price_dirams
|
||||
: Math.round(Number(unit_price_dirams));
|
||||
|
||||
const newLabel = current.affects_inventory === 0
|
||||
? (label == null ? current.label : String(label).trim() || current.label)
|
||||
: current.label;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE invoice_lines SET quantity = ?, unit_price_dirams = ?, label = ?
|
||||
WHERE id = ?
|
||||
`).run(qty, price, newLabel, id);
|
||||
}
|
||||
|
||||
export function removeLine(line_id) {
|
||||
getDb().prepare(`DELETE FROM invoice_lines WHERE id = ?`).run(Number(line_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the invoice: for each inventoried line, record an 'out' stock
|
||||
* movement (which decrements parts.quantity_on_hand atomically and rejects
|
||||
* if there's not enough stock). All movements + the status flip happen in
|
||||
* one transaction — if any line fails, nothing is decremented and the
|
||||
* draft stays pending.
|
||||
*/
|
||||
export function saveInvoice(invoice_id) {
|
||||
const db = getDb();
|
||||
const id = Number(invoice_id);
|
||||
|
||||
return db.transaction(() => {
|
||||
const invoice = db.prepare(`SELECT * FROM invoices WHERE id = ?`).get(id);
|
||||
if (!invoice) throw new Error(`invoice ${id} not found`);
|
||||
if (invoice.status !== 'pending') throw new Error('invoice already saved');
|
||||
|
||||
const lines = db.prepare(`
|
||||
SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order, id
|
||||
`).all(id);
|
||||
if (lines.length === 0) throw new Error('cannot save empty invoice');
|
||||
|
||||
const reference = `INV-${id}`;
|
||||
for (const line of lines) {
|
||||
if (line.affects_inventory !== 1) continue;
|
||||
recordMovement({
|
||||
part_id: line.part_id,
|
||||
movement_type: 'out',
|
||||
quantity: line.quantity,
|
||||
unit_price: line.unit_price_dirams / 100,
|
||||
reference
|
||||
});
|
||||
}
|
||||
|
||||
const total = lines.reduce(
|
||||
(sum, l) => sum + (l.quantity * l.unit_price_dirams),
|
||||
0
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE invoices
|
||||
SET status = 'saved', saved_at = datetime('now'), total_dirams = ?
|
||||
WHERE id = ?
|
||||
`).run(total, id);
|
||||
|
||||
return id;
|
||||
})();
|
||||
}
|
||||
|
||||
export function cancelInvoice(invoice_id) {
|
||||
getDb().prepare(`
|
||||
DELETE FROM invoices WHERE id = ? AND status = 'pending'
|
||||
`).run(Number(invoice_id));
|
||||
}
|
||||
@ -54,3 +54,28 @@ 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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending','saved')) DEFAULT 'pending',
|
||||
total_dirams INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
saved_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
part_id INTEGER REFERENCES parts(id) ON DELETE SET NULL,
|
||||
label TEXT,
|
||||
quantity INTEGER NOT NULL CHECK(quantity > 0),
|
||||
unit_price_dirams INTEGER NOT NULL DEFAULT 0,
|
||||
affects_inventory INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON invoice_lines(invoice_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_one_pending
|
||||
ON invoices(status) WHERE status = 'pending';
|
||||
|
||||
10
src/routes/invoices/[id]/+page.server.js
Normal file
10
src/routes/invoices/[id]/+page.server.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getInvoice } from '$lib/server/invoices.js';
|
||||
|
||||
export function load({ params }) {
|
||||
const result = getInvoice(params.id);
|
||||
if (!result || result.invoice.status !== 'saved') {
|
||||
throw error(404, 'Invoice not found');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
100
src/routes/invoices/[id]/+page.svelte
Normal file
100
src/routes/invoices/[id]/+page.svelte
Normal file
@ -0,0 +1,100 @@
|
||||
<script>
|
||||
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
$: lang = $locale;
|
||||
$: ({ invoice, lines } = data);
|
||||
|
||||
function lineLabel(line) {
|
||||
if (line.affects_inventory === 0) return line.label;
|
||||
return `${line.part_sku} — ${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="receipt">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>{$t('invoices.saved_title')} #{invoice.id}</h1>
|
||||
<p class="muted">{invoice.saved_at}</p>
|
||||
</div>
|
||||
<a href="/invoices/new" class="print-hide back">← {$t('invoices.new_another')}</a>
|
||||
</header>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('invoices.item')}</th>
|
||||
<th class="num">{$t('movements.quantity')}</th>
|
||||
<th class="num">{$t('movements.unit_price')}</th>
|
||||
<th class="num">{$t('invoices.line_total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lines as line}
|
||||
{@const lineTotal = line.quantity * line.unit_price_dirams}
|
||||
<tr>
|
||||
<td>{lineLabel(line)}</td>
|
||||
<td class="num">{line.quantity}</td>
|
||||
<td class="num">{formatMoney(line.unit_price_dirams, lang)}</td>
|
||||
<td class="num">{formatMoney(lineTotal, lang)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="num"><strong>{$t('invoices.saved_total')}</strong></td>
|
||||
<td class="num">
|
||||
<strong>{formatMoney(invoice.total_dirams, lang)} {$t('common.currency_short')}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<section class="pay">
|
||||
<p class="muted">{$t('invoices.saved_thanks')}</p>
|
||||
<img src="/payment-qr.jpg" alt="Payment QR code" class="qr" />
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.receipt { max-width: 720px; margin: 0 auto; }
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.head h1 { margin: 0; }
|
||||
.head .muted { margin: 0.25rem 0 0; font-size: 0.9rem; }
|
||||
.back {
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid #c8cfdc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
tfoot td { background: #f5f7fb; }
|
||||
.pay {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.qr {
|
||||
display: block;
|
||||
margin: 0.75rem auto 0;
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid #e5e8ee;
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
:global(.header), :global(.lang) { display: none !important; }
|
||||
.print-hide { display: none !important; }
|
||||
:global(body) { background: #fff; }
|
||||
:global(.container) { padding: 0; max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
121
src/routes/invoices/new/+page.server.js
Normal file
121
src/routes/invoices/new/+page.server.js
Normal file
@ -0,0 +1,121 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { listParts } from '$lib/server/parts.js';
|
||||
import {
|
||||
getOrCreatePendingInvoice,
|
||||
addLine,
|
||||
addCustomLine,
|
||||
updateLine,
|
||||
removeLine,
|
||||
saveInvoice,
|
||||
cancelInvoice
|
||||
} from '$lib/server/invoices.js';
|
||||
|
||||
export function load() {
|
||||
const { invoice, lines } = getOrCreatePendingInvoice();
|
||||
return {
|
||||
invoice,
|
||||
lines,
|
||||
parts: listParts()
|
||||
};
|
||||
}
|
||||
|
||||
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 const actions = {
|
||||
add_part_line: async ({ request }) => {
|
||||
const data = Object.fromEntries(await request.formData());
|
||||
const errors = {};
|
||||
if (!data.part_id) errors.add_part = 'invoices.errors.part_required';
|
||||
const qty = Number(data.quantity);
|
||||
if (!Number.isInteger(qty) || qty <= 0) errors.add_part = 'invoices.errors.quantity_required';
|
||||
if (Object.keys(errors).length) return fail(400, { errors, values: data });
|
||||
|
||||
const { invoice } = getOrCreatePendingInvoice();
|
||||
try {
|
||||
addLine({
|
||||
invoice_id: invoice.id,
|
||||
part_id: data.part_id,
|
||||
quantity: qty,
|
||||
unit_price_dirams: data.unit_price === '' || data.unit_price == null ? null : toDirams(data.unit_price)
|
||||
});
|
||||
} catch (err) {
|
||||
return fail(400, { errors: { add_part: 'invoices.errors.add_failed' }, values: data, errMsg: String(err.message) });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
add_custom_line: async ({ request }) => {
|
||||
const data = Object.fromEntries(await request.formData());
|
||||
const errors = {};
|
||||
const label = (data.label || '').trim();
|
||||
const qty = Number(data.quantity);
|
||||
if (!label) errors.add_custom = 'invoices.errors.label_required';
|
||||
if (!Number.isInteger(qty) || qty <= 0) errors.add_custom = 'invoices.errors.quantity_required';
|
||||
if (Object.keys(errors).length) return fail(400, { errors, values: data });
|
||||
|
||||
const { invoice } = getOrCreatePendingInvoice();
|
||||
addCustomLine({
|
||||
invoice_id: invoice.id,
|
||||
label,
|
||||
quantity: qty,
|
||||
unit_price_dirams: toDirams(data.unit_price)
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
update_line: async ({ request }) => {
|
||||
const data = Object.fromEntries(await request.formData());
|
||||
const id = Number(data.line_id);
|
||||
const qty = Number(data.quantity);
|
||||
if (!id) return fail(400, { errors: { line: 'invoices.errors.line_missing' } });
|
||||
if (!Number.isInteger(qty) || qty <= 0) {
|
||||
return fail(400, { errors: { [`line_${id}`]: 'invoices.errors.quantity_required' } });
|
||||
}
|
||||
try {
|
||||
updateLine(id, {
|
||||
quantity: qty,
|
||||
unit_price_dirams: data.unit_price === '' || data.unit_price == null ? null : toDirams(data.unit_price),
|
||||
label: data.label
|
||||
});
|
||||
} catch (err) {
|
||||
return fail(400, { errors: { [`line_${id}`]: 'invoices.errors.update_failed' }, errMsg: String(err.message) });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
remove_line: async ({ request }) => {
|
||||
const data = Object.fromEntries(await request.formData());
|
||||
const id = Number(data.line_id);
|
||||
if (id) removeLine(id);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
save_invoice: async () => {
|
||||
const { invoice, lines } = getOrCreatePendingInvoice();
|
||||
if (lines.length === 0) {
|
||||
return fail(400, { errors: { save: 'invoices.errors.empty' } });
|
||||
}
|
||||
let savedId;
|
||||
try {
|
||||
savedId = saveInvoice(invoice.id);
|
||||
} catch (err) {
|
||||
const msg = String(err.message);
|
||||
if (msg.includes('not enough stock')) {
|
||||
return fail(400, { errors: { save: 'invoices.errors.not_enough_stock' }, errMsg: msg });
|
||||
}
|
||||
return fail(400, { errors: { save: 'invoices.errors.save_failed' }, errMsg: msg });
|
||||
}
|
||||
throw redirect(303, `/invoices/${savedId}`);
|
||||
},
|
||||
|
||||
cancel_invoice: async () => {
|
||||
const { invoice } = getOrCreatePendingInvoice();
|
||||
cancelInvoice(invoice.id);
|
||||
throw redirect(303, '/invoices/new');
|
||||
}
|
||||
};
|
||||
294
src/routes/invoices/new/+page.svelte
Normal file
294
src/routes/invoices/new/+page.svelte
Normal file
@ -0,0 +1,294 @@
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: lang = $locale;
|
||||
$: ({ invoice, lines, parts } = data);
|
||||
|
||||
$: errors = form?.errors ?? {};
|
||||
|
||||
// Add-a-part section state
|
||||
let partSearch = '';
|
||||
let partId = '';
|
||||
let partQty = '1';
|
||||
let partPrice = '';
|
||||
|
||||
$: 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)
|
||||
);
|
||||
})();
|
||||
|
||||
$: selectedPart = parts?.find((p) => String(p.id) === String(partId));
|
||||
$: visibleParts =
|
||||
selectedPart && !filteredParts.some((p) => p.id === selectedPart.id)
|
||||
? [selectedPart, ...filteredParts]
|
||||
: filteredParts;
|
||||
|
||||
// Auto-fill the unit-price input from the selected part's sale price,
|
||||
// unless the user has typed something different.
|
||||
let lastAutoPrice = '';
|
||||
$: {
|
||||
if (selectedPart) {
|
||||
const expected = selectedPart.sale_price
|
||||
? (selectedPart.sale_price / 100).toFixed(2)
|
||||
: '';
|
||||
if (expected && (partPrice === '' || partPrice === lastAutoPrice)) {
|
||||
partPrice = expected;
|
||||
lastAutoPrice = expected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add-custom-line state
|
||||
let customLabel = '';
|
||||
let customQty = '1';
|
||||
let customPrice = '';
|
||||
|
||||
$: runningTotalDirams = lines.reduce(
|
||||
(sum, l) => sum + (l.quantity * l.unit_price_dirams),
|
||||
0
|
||||
);
|
||||
|
||||
function lineLabel(line) {
|
||||
if (line.affects_inventory === 0) return line.label;
|
||||
return `${line.part_sku} — ${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`;
|
||||
}
|
||||
|
||||
function confirmCancel(event) {
|
||||
if (!confirm($t('invoices.cancel_confirm'))) event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="title">{$t('invoices.title')} <span class="muted">#{invoice.id}</span></h1>
|
||||
|
||||
{#if form?.errMsg}
|
||||
<div class="error">{form.errMsg}</div>
|
||||
{/if}
|
||||
|
||||
<div class="builder">
|
||||
<div class="col left">
|
||||
<section class="card">
|
||||
<h2>{$t('invoices.add_part')}</h2>
|
||||
<form method="POST" action="?/add_part_line" class="stack" use:enhance>
|
||||
<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(6, Math.max(3, visibleParts.length + 1))}>
|
||||
<option value="">—</option>
|
||||
{#each visibleParts as p}
|
||||
<option value={String(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}
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('movements.quantity')} *
|
||||
<input name="quantity" type="number" min="1" step="1" required bind:value={partQty} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('movements.unit_price')} ({$t('common.currency_short')})
|
||||
<input name="unit_price" type="number" step="0.01" min="0" bind:value={partPrice} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if errors.add_part}
|
||||
<span class="field-error">{$t(errors.add_part)}</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit">{$t('common.add')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{$t('invoices.add_custom')}</h2>
|
||||
<form method="POST" action="?/add_custom_line" class="stack" use:enhance>
|
||||
<label>
|
||||
{$t('invoices.custom_label')} *
|
||||
<input name="label" type="text" required bind:value={customLabel}
|
||||
placeholder={$t('invoices.custom_placeholder')} />
|
||||
</label>
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('movements.quantity')} *
|
||||
<input name="quantity" type="number" min="1" step="1" required bind:value={customQty} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('movements.unit_price')} ({$t('common.currency_short')})
|
||||
<input name="unit_price" type="number" step="0.01" min="0" bind:value={customPrice} />
|
||||
</label>
|
||||
</div>
|
||||
{#if errors.add_custom}
|
||||
<span class="field-error">{$t(errors.add_custom)}</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" class="secondary">{$t('common.add')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="col right">
|
||||
<div class="running">
|
||||
<span class="muted">{$t('invoices.running_total')}</span>
|
||||
<strong class="total">{formatMoney(runningTotalDirams, lang)} {$t('common.currency_short')}</strong>
|
||||
</div>
|
||||
|
||||
<section class="lines">
|
||||
<h2>{$t('invoices.lines')}</h2>
|
||||
{#if lines.length === 0}
|
||||
<p class="muted">{$t('invoices.no_lines')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('invoices.item')}</th>
|
||||
<th class="num">{$t('movements.quantity')}</th>
|
||||
<th class="num">{$t('movements.unit_price')}</th>
|
||||
<th class="num">{$t('invoices.line_total')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lines as line (line.id)}
|
||||
{@const lineTotal = line.quantity * line.unit_price_dirams}
|
||||
<tr>
|
||||
<td>
|
||||
<div>{lineLabel(line)}</div>
|
||||
{#if line.affects_inventory === 0}
|
||||
<small class="muted">{$t('invoices.no_inventory_impact')}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
<form method="POST" action="?/update_line" class="inline" use:enhance>
|
||||
<input type="hidden" name="line_id" value={line.id} />
|
||||
<input type="hidden" name="unit_price" value={(line.unit_price_dirams / 100).toFixed(2)} />
|
||||
<input name="quantity" type="number" min="1" step="1"
|
||||
value={line.quantity} on:change={(e) => e.currentTarget.form.requestSubmit()} />
|
||||
</form>
|
||||
{#if errors[`line_${line.id}`]}
|
||||
<small class="field-error">{$t(errors[`line_${line.id}`])}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
<form method="POST" action="?/update_line" class="inline" use:enhance>
|
||||
<input type="hidden" name="line_id" value={line.id} />
|
||||
<input type="hidden" name="quantity" value={line.quantity} />
|
||||
<input name="unit_price" type="number" step="0.01" min="0"
|
||||
value={(line.unit_price_dirams / 100).toFixed(2)}
|
||||
on:change={(e) => e.currentTarget.form.requestSubmit()} />
|
||||
</form>
|
||||
</td>
|
||||
<td class="num">{formatMoney(lineTotal, lang)}</td>
|
||||
<td>
|
||||
<form method="POST" action="?/remove_line" class="inline" use:enhance>
|
||||
<input type="hidden" name="line_id" value={line.id} />
|
||||
<button type="submit" class="secondary remove" aria-label={$t('common.delete')}>×</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="num"><strong>{$t('common.total')}</strong></td>
|
||||
<td class="num"><strong>{formatMoney(runningTotalDirams, lang)} {$t('common.currency_short')}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if errors.save}
|
||||
<div class="error">{$t(errors.save)}</div>
|
||||
{/if}
|
||||
|
||||
<section class="commit">
|
||||
<form method="POST" action="?/save_invoice" class="inline" use:enhance>
|
||||
<button type="submit" class="save">{$t('invoices.save')}</button>
|
||||
</form>
|
||||
<form method="POST" action="?/cancel_invoice" class="inline" on:submit={confirmCancel} use:enhance>
|
||||
<button type="submit" class="danger">{$t('invoices.cancel')}</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.title { margin: 0 0 1rem; }
|
||||
.builder {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.col { display: grid; gap: 1.25rem; }
|
||||
@media (max-width: 900px) {
|
||||
.builder { grid-template-columns: 1fr; }
|
||||
}
|
||||
.running {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.1rem;
|
||||
background: #fff;
|
||||
border: 2px solid #006a4e;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.total {
|
||||
font-size: 1.35rem;
|
||||
color: #006a4e;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
section.card { padding: 1rem 1.25rem; }
|
||||
section.card h2 { margin-top: 0; }
|
||||
.lines table { margin-top: 0.5rem; }
|
||||
.lines tfoot td { background: #f5f7fb; }
|
||||
.lines input[type=number] {
|
||||
width: 6rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
form.inline { display: inline; }
|
||||
.part-search { margin-bottom: 0.35rem; }
|
||||
.field-error { color: #8a1f1b; font-size: 0.8rem; }
|
||||
.actions { display: flex; gap: 0.6rem; }
|
||||
.remove {
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.commit {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e8ee;
|
||||
}
|
||||
.commit button.save {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
BIN
static/payment-qr.jpg
Normal file
BIN
static/payment-qr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Reference in New Issue
Block a user