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:
David Beccue
2026-05-16 12:57:49 +05:00
parent 9d756e2940
commit 00ee9fb1fe
10 changed files with 809 additions and 0 deletions

View File

@ -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>

View File

@ -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",

View File

@ -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
View 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));
}

View File

@ -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';

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

View 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>

View 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');
}
};

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB