feat(invoices): M2 CRUD — draft lifecycle, totals, list + detail

Full draft → sent → paid flow with the supporting surface:

Data layer
- totals.ts: pure computeLineTotal / computeInvoiceTotals (testable
  without Dexie); per-line rounding then per-rate summing so the
  breakdown equals the sum-of-lines exactly
- queries.ts: useAllInvoices (live, overdue derived on read),
  useInvoiceClients, computeStats per currency, formatAmount
- stores/settings.svelte.ts: singleton with stable sentinel id,
  ensureSettings() lazy-creates, takeNextNumber() atomic via Dexie
  rw-transaction (plaintext-only fields → no crypto breaks the tx)
- stores/invoices.svelte.ts: create / update / updateLines (recomputes
  totals) / markSent / markPaid / void / duplicate / soft-delete; drafts
  only editable, paid can't be voided, sent/paid can't be deleted

Components
- StatusBadge, LinesEditor (add/remove/reorder, live per-line totals,
  minor-unit conversion on type), ClientPicker (contacts + invoice
  client book, manual entry fallback, snapshot binding),
  SenderProfileForm (sender + sequence + defaults), InvoiceForm
  (create + edit with live totals + VAT breakdown)

Views & routes
- DetailView with full action bar (edit/duplicate/mark sent/mark paid/
  void/delete), lines table, per-rate totals block
- ListView rewritten: status chips with counts, stats cards
  (open/overdue/YTD fakturiert/YTD bezahlt) scoped to primary currency,
  search, row-click to detail
- routes: /invoices/new, /invoices/[id], /invoices/[id]/edit (with
  edit-lock for non-drafts), /invoices/settings

Design notes addressed
- Number sequence atomicity: rw-transaction around plaintext fields
  guards same-device race; cross-device offline collision documented
  in settings store header as a known gap, acceptable for solo-MVP
- Edit-after-send: drafts only are editable; to revise a sent invoice,
  void and duplicate (keeps bookkeeping evidence intact)

Plan: docs/plans/invoices-module.md §M2.
Next: M3 pdf-lib renderer + M5 swissqrbill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 15:40:11 +02:00
parent 248137ec43
commit 8d00ee0697
16 changed files with 2704 additions and 61 deletions

View file

@ -1,56 +1,158 @@
<!--
Invoices — ListView (M1 skeleton)
Empty state + "+ Neu"-button placeholder. M2 brings the full list, filter
chips, and stats cards. Plan: docs/plans/invoices-module.md.
Invoices — ListView (M2)
Status filter + stats cards (open / overdue / invoiced YTD / paid YTD) +
search + row navigation. FAB → /invoices/new, settings icon → /invoices/settings.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { invoiceTable } from './collections';
import { STATUS_LABELS, STATUS_COLORS, CURRENCIES } from './constants';
import type { LocalInvoice } from './types';
import { goto } from '$app/navigation';
import { useAllInvoices, computeStats, formatAmount, searchInvoices } from './queries';
import { STATUS_LABELS } from './constants';
import StatusBadge from './components/StatusBadge.svelte';
import type { Invoice, InvoiceStatus, Currency } from './types';
const invoices$ = useLiveQueryWithDefault(async () => {
const rows = await invoiceTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
return (await decryptRecords('invoices', visible)) as LocalInvoice[];
}, [] as LocalInvoice[]);
const invoices = $derived(invoices$.value);
const invoices$ = useAllInvoices();
const invoices = $derived(invoices$.value ?? []);
function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string {
const { symbol, minorUnit } = CURRENCIES[currency];
const value = minor / minorUnit;
return `${symbol} ${value.toFixed(2)}`;
let activeStatus = $state<InvoiceStatus | 'all'>('all');
let searchQuery = $state('');
const currentYear = new Date().getFullYear();
const stats = $derived(computeStats(invoices, currentYear));
const filtered = $derived.by(() => {
let out = invoices;
if (activeStatus !== 'all') out = out.filter((i) => i.status === activeStatus);
if (searchQuery.trim()) out = searchInvoices(out, searchQuery.trim());
return out;
});
function openInvoice(i: Invoice) {
goto(`/invoices/${i.id}`);
}
/** Show the first currency that has any activity, falling back to CHF. */
function primaryCurrency(map: Record<Currency, number>): Currency {
for (const c of ['CHF', 'EUR', 'USD'] as Currency[]) {
if (map[c] > 0) return c;
}
return 'CHF';
}
const openCurrency = $derived(primaryCurrency(stats.openByCurrency));
const overdueCurrency = $derived(primaryCurrency(stats.overdueByCurrency));
const ytdCurrency = $derived(primaryCurrency(stats.invoicedYtdByCurrency));
</script>
<div class="invoices-shell">
<header class="head">
<div>
<h1>Rechnungen</h1>
<p class="subtitle">Outbound-Finance — Rechnungen stellen und verfolgen</p>
<p class="subtitle">Rechnungen stellen und Zahlungen verfolgen</p>
</div>
<div class="head-actions">
<button
class="btn-icon"
type="button"
title="Einstellungen"
aria-label="Einstellungen"
onclick={() => goto('/invoices/settings')}
>
</button>
<button class="btn-primary" type="button" onclick={() => goto('/invoices/new')}>
+ Neue Rechnung
</button>
</div>
<button class="btn-primary" type="button" disabled title="M2">+ Neu</button>
</header>
<section class="stats">
<div class="stat">
<div class="stat-label">Offen</div>
<div class="stat-value">{formatAmount(stats.openByCurrency[openCurrency], openCurrency)}</div>
<div class="stat-sub">
{stats.totalByStatus.sent + stats.totalByStatus.overdue} Rechnungen
</div>
</div>
<div class="stat stat-warn" class:empty={stats.overdueByCurrency[overdueCurrency] === 0}>
<div class="stat-label">Überfällig</div>
<div class="stat-value">
{formatAmount(stats.overdueByCurrency[overdueCurrency], overdueCurrency)}
</div>
<div class="stat-sub">{stats.totalByStatus.overdue} Rechnungen</div>
</div>
<div class="stat">
<div class="stat-label">Fakturiert {currentYear}</div>
<div class="stat-value">
{formatAmount(stats.invoicedYtdByCurrency[ytdCurrency], ytdCurrency)}
</div>
</div>
<div class="stat">
<div class="stat-label">Bezahlt {currentYear}</div>
<div class="stat-value">
{formatAmount(stats.paidYtdByCurrency[ytdCurrency], ytdCurrency)}
</div>
</div>
</section>
<section class="filters">
<div class="chips">
<button
class="chip"
class:active={activeStatus === 'all'}
onclick={() => (activeStatus = 'all')}
>
Alle <span class="count">{invoices.length}</span>
</button>
{#each ['draft', 'sent', 'overdue', 'paid', 'void'] as status (status)}
<button
class="chip"
class:active={activeStatus === status}
onclick={() => (activeStatus = status as InvoiceStatus)}
>
{STATUS_LABELS[status as InvoiceStatus].de}
<span class="count">{stats.totalByStatus[status as InvoiceStatus]}</span>
</button>
{/each}
</div>
<input
class="search"
type="search"
placeholder="Suchen (Nummer, Kunde, Betreff)"
bind:value={searchQuery}
/>
</section>
{#if invoices.length === 0}
<div class="empty">
<div class="empty-icon">📄</div>
<h2>Noch keine Rechnungen</h2>
<p>Stelle deine erste Rechnung mit automatischem PDF-Export und Schweizer QR-Bill.</p>
<p class="note">M1 Skelett — Erstellen-Flow folgt in M2.</p>
<p>Stelle deine erste Rechnung — inklusive PDF-Export und Schweizer QR-Bill (M5).</p>
<button class="btn-primary" onclick={() => goto('/invoices/new')}>
Erste Rechnung erstellen
</button>
</div>
{:else if filtered.length === 0}
<div class="empty">
<p>Keine Rechnungen gefunden.</p>
</div>
{:else}
<ul class="list">
{#each invoices as invoice (invoice.id)}
<li class="row">
<span class="number">{invoice.number}</span>
<span class="client">{invoice.clientSnapshot?.name ?? '—'}</span>
<span class="amount">{formatAmount(invoice.totals.gross, invoice.currency)}</span>
<span class="status" style="--dot: {STATUS_COLORS[invoice.status]}">
<span class="dot"></span>
{STATUS_LABELS[invoice.status].de}
</span>
<ul class="list" role="list">
{#each filtered as invoice (invoice.id)}
<li>
<button class="row" onclick={() => openInvoice(invoice)}>
<span class="number">{invoice.number}</span>
<span class="client">
<span class="client-name">{invoice.clientSnapshot.name}</span>
{#if invoice.subject}
<span class="client-subject">{invoice.subject}</span>
{/if}
</span>
<span class="date">{invoice.dueDate}</span>
<span class="amount">
{formatAmount(invoice.totals.gross, invoice.currency)}
</span>
<span class="status"><StatusBadge status={invoice.status} /></span>
</button>
</li>
{/each}
</ul>
@ -60,7 +162,7 @@
<style>
.invoices-shell {
padding: 1.5rem;
max-width: 1000px;
max-width: 1100px;
margin: 0 auto;
}
@ -69,6 +171,8 @@
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.head h1 {
@ -83,19 +187,126 @@
font-size: 0.9rem;
}
.head-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-primary {
background: #059669;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
padding: 0.55rem 1.1rem;
border-radius: 0.4rem;
border: 0;
font-weight: 500;
cursor: pointer;
font-size: 0.95rem;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
.btn-icon {
width: 2.25rem;
height: 2.25rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
cursor: pointer;
font-size: 1rem;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.stat {
padding: 0.9rem 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
}
.stat-label {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.stat-value {
margin-top: 0.25rem;
font-size: 1.3rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-sub {
margin-top: 0.15rem;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.stat-warn:not(.empty) {
border-color: #fecaca;
background: #fef2f2;
}
.stat-warn:not(.empty) .stat-value {
color: #b91c1c;
}
.filters {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.chips {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 999px;
cursor: pointer;
font-size: 0.85rem;
}
.chip.active {
background: #065f46;
color: white;
border-color: #065f46;
}
.chip .count {
background: rgba(0, 0, 0, 0.08);
padding: 0 0.4rem;
border-radius: 999px;
font-size: 0.75rem;
}
.chip.active .count {
background: rgba(255, 255, 255, 0.2);
}
.search {
padding: 0.45rem 0.75rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.9rem;
min-width: 240px;
}
.empty {
@ -117,16 +328,10 @@
}
.empty p {
margin: 0.25rem 0;
margin: 0.25rem 0 1rem;
font-size: 0.9rem;
}
.note {
margin-top: 1rem !important;
font-size: 0.8rem;
opacity: 0.7;
}
.list {
list-style: none;
padding: 0;
@ -138,13 +343,22 @@
.row {
display: grid;
grid-template-columns: 7rem 1fr auto 7rem;
grid-template-columns: 7rem 1fr auto 7rem 7rem;
gap: 1rem;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
cursor: pointer;
text-align: left;
font: inherit;
}
.row:hover {
border-color: #059669;
background: #f0fdf4;
}
.number {
@ -154,25 +368,30 @@
}
.client {
display: flex;
flex-direction: column;
min-width: 0;
}
.client-name {
font-weight: 500;
}
.client-subject {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date,
.amount {
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.amount {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
}
</style>

View file

@ -0,0 +1,221 @@
<!--
ClientPicker — picks a client and emits a snapshot the InvoiceForm freezes
onto the invoice at send time.
Sources:
- Contacts module (existing CRM entries)
- InvoiceClients (per-invoice book, optional — Phase 1.5)
- Manual entry (no backing record — snapshot only)
The picker ALWAYS emits a snapshot; the clientId / clientSource tell the
downstream store which table (if any) the backing record lives in.
-->
<script lang="ts">
import { useAllContacts } from '$lib/modules/contacts/queries';
import { useInvoiceClients } from '../queries';
import type { InvoiceClientSnapshot, ClientSource } from '../types';
interface Props {
clientId: string | null;
clientSource: ClientSource;
snapshot: InvoiceClientSnapshot;
}
let {
clientId = $bindable(null),
clientSource = $bindable('invoice-client'),
snapshot = $bindable({ name: '', address: '', email: '' }),
}: Props = $props();
const contacts$ = useAllContacts();
const invoiceClients$ = useInvoiceClients();
const contacts = $derived(contacts$.value ?? []);
const invoiceClients = $derived(invoiceClients$.value ?? []);
let query = $state('');
let showSuggest = $state(false);
const suggestions = $derived.by(() => {
const q = query.toLowerCase().trim();
if (!q) return [];
const fromContacts = contacts
.filter((c) => (c.displayName ?? '').toLowerCase().includes(q))
.map((c) => ({
id: c.id,
source: 'contact' as ClientSource,
name: c.displayName ?? 'Unbenannter Kontakt',
email: c.email,
address: [c.street, c.postalCode && c.city ? `${c.postalCode} ${c.city}` : null]
.filter(Boolean)
.join('\n'),
}));
const fromClients = invoiceClients
.filter((c) => c.name.toLowerCase().includes(q))
.map((c) => ({
id: c.id,
source: 'invoice-client' as ClientSource,
name: c.name,
email: c.email,
address: c.address,
}));
return [...fromContacts, ...fromClients].slice(0, 8);
});
function select(s: (typeof suggestions)[number]) {
clientId = s.id;
clientSource = s.source;
snapshot = {
name: s.name,
address: s.address ?? undefined,
email: s.email ?? undefined,
};
query = s.name;
showSuggest = false;
}
function clearPick() {
clientId = null;
clientSource = 'invoice-client';
}
function setName(v: string) {
query = v;
snapshot = { ...snapshot, name: v };
clearPick();
showSuggest = v.length > 0;
}
</script>
<div class="picker">
<label class="field">
<span class="label">Kunde *</span>
<input
type="text"
placeholder="Name tippen oder aus Kontakten wählen"
value={query || snapshot.name}
oninput={(e) => setName(e.currentTarget.value)}
onfocus={() => (showSuggest = query.length > 0)}
onblur={() => setTimeout(() => (showSuggest = false), 150)}
/>
{#if showSuggest && suggestions.length > 0}
<div class="suggest">
{#each suggestions as s (s.source + ':' + s.id)}
<button type="button" class="suggest-row" onclick={() => select(s)}>
<span class="suggest-name">{s.name}</span>
<span class="suggest-source">
{s.source === 'contact' ? 'aus Kontakten' : 'aus Rechnungen'}
</span>
</button>
{/each}
</div>
{/if}
</label>
<label class="field">
<span class="label">Adresse</span>
<textarea
rows="3"
placeholder="Bahnhofstrasse 1&#10;8000 Zürich"
value={snapshot.address ?? ''}
oninput={(e) => (snapshot = { ...snapshot, address: e.currentTarget.value || undefined })}
></textarea>
</label>
<label class="field">
<span class="label">E-Mail</span>
<input
type="email"
placeholder="kontakt@kunde.ch"
value={snapshot.email ?? ''}
oninput={(e) => (snapshot = { ...snapshot, email: e.currentTarget.value || undefined })}
/>
</label>
<label class="field">
<span class="label">USt-IdNr. / MwSt-Nr.</span>
<input
type="text"
placeholder="CHE-123.456.789 MWST"
value={snapshot.vatNumber ?? ''}
oninput={(e) => (snapshot = { ...snapshot, vatNumber: e.currentTarget.value || undefined })}
/>
</label>
</div>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
position: relative;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.field input,
.field textarea {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.95rem;
font-family: inherit;
}
.field textarea {
resize: vertical;
}
.suggest {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
max-height: 14rem;
overflow-y: auto;
}
.suggest-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
background: white;
border: 0;
border-bottom: 1px solid var(--color-border, #e2e8f0);
cursor: pointer;
text-align: left;
font-size: 0.9rem;
}
.suggest-row:last-child {
border-bottom: 0;
}
.suggest-row:hover {
background: var(--color-surface-muted, #f1f5f9);
}
.suggest-name {
font-weight: 500;
}
.suggest-source {
font-size: 0.75rem;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,332 @@
<!--
InvoiceForm — create or edit a draft invoice.
Edit is disabled for non-draft statuses; the DetailView routes to this
form only when status === 'draft'.
-->
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import ClientPicker from './ClientPicker.svelte';
import LinesEditor from './LinesEditor.svelte';
import { invoiceSettingsStore } from '../stores/settings.svelte';
import { invoicesStore } from '../stores/invoices.svelte';
import { computeInvoiceTotals } from '../totals';
import { formatAmount } from '../queries';
import { CURRENCIES } from '../constants';
import type {
Invoice,
LocalInvoiceLine,
InvoiceClientSnapshot,
ClientSource,
Currency,
} from '../types';
interface Props {
existing?: Invoice;
}
let { existing }: Props = $props();
const isEdit = $derived(Boolean(existing));
// ─── Form state ──────────────────────────────────────────
// `existing` is captured once at mount — the form is keyed on invoice id
// at the route level, so prop changes remount rather than re-initialise
// mid-edit. `untrack` makes that intent explicit to svelte-check.
const initial = untrack(() => existing);
let clientId = $state<string | null>(initial?.clientId ?? null);
let clientSource = $state<ClientSource>(initial?.clientSource ?? 'invoice-client');
let snapshot = $state<InvoiceClientSnapshot>(
initial?.clientSnapshot ?? { name: '', address: '', email: '' }
);
let currency = $state<Currency>(initial?.currency ?? 'CHF');
let issueDate = $state(initial?.issueDate ?? new Date().toISOString().slice(0, 10));
let dueDate = $state(initial?.dueDate ?? '');
let subject = $state<string>(initial?.subject ?? '');
let notes = $state<string>(initial?.notes ?? '');
let terms = $state<string>(initial?.terms ?? '');
let lines = $state<LocalInvoiceLine[]>(
initial?.lines?.map((l) => ({
id: l.id,
title: l.title,
description: l.description,
quantity: l.quantity,
unit: l.unit,
unitPrice: l.unitPrice,
vatRate: l.vatRate,
discount: l.discount,
})) ?? []
);
let saving = $state(false);
let error = $state<string | null>(null);
// ─── Defaults from settings ──────────────────────────────
$effect(() => {
if (isEdit) return;
invoiceSettingsStore.getDefaults().then((d) => {
if (!currency) currency = d.currency;
if (!terms) terms = d.terms ?? '';
if (!dueDate) {
const base = new Date(issueDate);
base.setDate(base.getDate() + d.dueDays);
dueDate = base.toISOString().slice(0, 10);
}
});
});
// ─── Derived totals (live preview) ───────────────────────
const totals = $derived(computeInvoiceTotals(lines));
const vatRegime = $derived<'CH' | 'DE'>(currency === 'EUR' ? 'DE' : 'CH');
// ─── Save ────────────────────────────────────────────────
async function saveDraft() {
error = null;
if (!snapshot.name?.trim()) {
error = 'Kunde ist erforderlich';
return;
}
if (lines.length === 0) {
error = 'Mindestens eine Position hinzufügen';
return;
}
saving = true;
try {
if (isEdit && existing) {
await invoicesStore.updateLines(existing.id, lines);
await invoicesStore.updateInvoice(existing.id, {
clientId,
clientSource,
clientSnapshot: snapshot,
currency,
issueDate,
dueDate,
subject: subject || null,
notes: notes || null,
terms: terms || null,
});
goto(`/invoices/${existing.id}`);
} else {
const newId = await invoicesStore.createInvoice({
clientId,
clientSource,
clientSnapshot: snapshot,
currency,
issueDate,
dueDate,
lines,
subject: subject || null,
notes: notes || null,
terms: terms || null,
});
goto(`/invoices/${newId}`);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen';
} finally {
saving = false;
}
}
</script>
<form
onsubmit={(e) => {
e.preventDefault();
saveDraft();
}}
class="form"
>
<section class="section">
<h3>Kunde</h3>
<ClientPicker bind:clientId bind:clientSource bind:snapshot />
</section>
<section class="section">
<h3>Rechnung</h3>
<div class="grid-3">
<label class="field">
<span>Betreff</span>
<input type="text" placeholder="Beratungsleistung April" bind:value={subject} />
</label>
<label class="field">
<span>Währung</span>
<select bind:value={currency}>
{#each Object.keys(CURRENCIES) as c (c)}
<option value={c}>{c}</option>
{/each}
</select>
</label>
<label class="field">
<span>Rechnungsdatum</span>
<input type="date" bind:value={issueDate} />
</label>
<label class="field">
<span>Fällig am</span>
<input type="date" bind:value={dueDate} />
</label>
</div>
</section>
<section class="section">
<h3>Positionen</h3>
<LinesEditor bind:lines {currency} {vatRegime} />
</section>
<section class="section totals-section">
<h3>Summe</h3>
<dl class="totals">
<dt>Netto</dt>
<dd>{formatAmount(totals.net, currency)}</dd>
{#each totals.vatBreakdown as b (b.rate)}
<dt>MwSt. {b.rate}%</dt>
<dd>{formatAmount(b.tax, currency)}</dd>
{/each}
<dt class="gross-label">Total</dt>
<dd class="gross-value">{formatAmount(totals.gross, currency)}</dd>
</dl>
</section>
<section class="section">
<h3>Notizen & Zahlungsbedingungen</h3>
<label class="field">
<span>Notizen (unter den Positionen)</span>
<textarea rows="2" bind:value={notes}></textarea>
</label>
<label class="field">
<span>Zahlungsbedingungen / AGB</span>
<textarea rows="2" bind:value={terms}></textarea>
</label>
</section>
{#if error}
<div class="error">{error}</div>
{/if}
<div class="actions">
<button type="button" class="btn-secondary" onclick={() => history.back()}> Abbrechen </button>
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? 'Speichert …' : isEdit ? 'Änderungen speichern' : 'Als Entwurf speichern'}
</button>
</div>
</form>
<style>
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.field > span {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.field input,
.field textarea,
.field select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.95rem;
font-family: inherit;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
}
.totals-section {
background: var(--color-surface-muted, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 1rem;
}
.totals {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.25rem 1rem;
margin: 0;
font-variant-numeric: tabular-nums;
}
.totals dt,
.totals dd {
margin: 0;
}
.totals dd {
text-align: right;
}
.gross-label,
.gross-value {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e2e8f0);
font-weight: 600;
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-primary,
.btn-secondary {
padding: 0.55rem 1.25rem;
border-radius: 0.4rem;
font-weight: 500;
cursor: pointer;
font-size: 0.95rem;
}
.btn-primary {
background: #059669;
color: white;
border: 0;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: white;
color: var(--color-text, #0f172a);
border: 1px solid var(--color-border, #e2e8f0);
}
</style>

View file

@ -0,0 +1,262 @@
<!--
LinesEditor — the per-line table on the InvoiceForm.
Two-way binds the array so the parent sees edits immediately; parent
recomputes totals from the same array via the pure helper in totals.ts.
Input convention: unitPrice comes in as MAJOR units (150.00 CHF) from the
UI because users type "150.00"; we convert to minor units (15000) on
blur / emit. All in-component math uses minor units.
-->
<script lang="ts">
import { VAT_RATES_CH, VAT_RATES_DE, CURRENCIES } from '../constants';
import { computeLineTotal } from '../totals';
import type { LocalInvoiceLine, Currency } from '../types';
interface Props {
lines: LocalInvoiceLine[];
currency: Currency;
vatRegime?: 'CH' | 'DE';
}
let { lines = $bindable([]), currency, vatRegime = 'CH' }: Props = $props();
const vatOptions = $derived(vatRegime === 'CH' ? VAT_RATES_CH : VAT_RATES_DE);
const minorUnit = $derived(CURRENCIES[currency].minorUnit);
function majorToMinor(val: number): number {
return Math.round(val * minorUnit);
}
function minorToMajor(val: number): number {
return val / minorUnit;
}
function addLine() {
lines = [
...lines,
{
id: crypto.randomUUID(),
title: '',
description: null,
quantity: 1,
unit: null,
unitPrice: 0,
vatRate: vatRegime === 'CH' ? 8.1 : 19,
discount: null,
},
];
}
function removeLine(id: string) {
lines = lines.filter((l) => l.id !== id);
}
function moveLine(id: string, dir: -1 | 1) {
const idx = lines.findIndex((l) => l.id === id);
if (idx < 0) return;
const next = idx + dir;
if (next < 0 || next >= lines.length) return;
const copy = [...lines];
[copy[idx], copy[next]] = [copy[next], copy[idx]];
lines = copy;
}
function updateLine(id: string, patch: Partial<LocalInvoiceLine>) {
lines = lines.map((l) => (l.id === id ? { ...l, ...patch } : l));
}
function formatMinor(minor: number): string {
return (minor / minorUnit).toFixed(2);
}
</script>
<div class="editor">
<div class="head">
<span class="col-title">Position</span>
<span class="col-qty">Menge</span>
<span class="col-unit">Einheit</span>
<span class="col-price">Einzelpreis</span>
<span class="col-vat">MwSt.</span>
<span class="col-total">Total</span>
<span class="col-actions"></span>
</div>
{#if lines.length === 0}
<p class="empty">Noch keine Positionen. Füge die erste hinzu.</p>
{/if}
{#each lines as line (line.id)}
{@const { net, tax } = computeLineTotal(line)}
<div class="row">
<input
class="col-title"
type="text"
placeholder="Titel der Position"
value={line.title}
oninput={(e) => updateLine(line.id, { title: e.currentTarget.value })}
/>
<input
class="col-qty"
type="number"
min="0"
step="0.01"
value={line.quantity}
oninput={(e) => updateLine(line.id, { quantity: Number(e.currentTarget.value) || 0 })}
/>
<input
class="col-unit"
type="text"
placeholder="Std"
value={line.unit ?? ''}
oninput={(e) => updateLine(line.id, { unit: e.currentTarget.value || null })}
/>
<input
class="col-price"
type="number"
min="0"
step="0.01"
value={minorToMajor(line.unitPrice)}
oninput={(e) =>
updateLine(line.id, { unitPrice: majorToMinor(Number(e.currentTarget.value) || 0) })}
/>
<select
class="col-vat"
value={line.vatRate}
onchange={(e) => updateLine(line.id, { vatRate: Number(e.currentTarget.value) })}
>
{#each vatOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<span class="col-total total-cell">
<strong>{formatMinor(net + tax)}</strong>
<small>netto {formatMinor(net)}</small>
</span>
<span class="col-actions">
<button
type="button"
title="Nach oben"
onclick={() => moveLine(line.id, -1)}
aria-label="Nach oben">↑</button
>
<button
type="button"
title="Nach unten"
onclick={() => moveLine(line.id, 1)}
aria-label="Nach unten">↓</button
>
<button
type="button"
class="remove"
title="Entfernen"
onclick={() => removeLine(line.id)}
aria-label="Entfernen">×</button
>
</span>
</div>
{/each}
<button type="button" class="add" onclick={addLine}>+ Position</button>
</div>
<style>
.editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.head,
.row {
display: grid;
grid-template-columns: 3fr 0.8fr 0.8fr 1.2fr 1.4fr 1.2fr auto;
gap: 0.5rem;
align-items: center;
}
.head {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--color-text-muted, #64748b);
padding: 0 0.5rem;
}
.row {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 0.5rem;
}
.row input,
.row select {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.35rem;
font-size: 0.9rem;
min-width: 0;
}
input.col-title {
width: 100%;
}
.total-cell {
display: flex;
flex-direction: column;
font-variant-numeric: tabular-nums;
text-align: right;
}
.total-cell small {
color: var(--color-text-muted, #64748b);
font-size: 0.75rem;
}
.col-actions {
display: flex;
gap: 0.2rem;
}
.col-actions button {
width: 1.8rem;
height: 1.8rem;
border: 1px solid var(--color-border, #e2e8f0);
background: white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
}
.col-actions .remove {
color: #ef4444;
}
.empty {
padding: 1rem;
color: var(--color-text-muted, #64748b);
text-align: center;
font-size: 0.9rem;
background: var(--color-surface-muted, #f8fafc);
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: 0.5rem;
margin: 0;
}
.add {
align-self: flex-start;
padding: 0.4rem 0.75rem;
border: 1px dashed var(--color-border, #e2e8f0);
background: transparent;
border-radius: 0.35rem;
cursor: pointer;
color: var(--color-text-muted, #64748b);
font-size: 0.85rem;
}
.add:hover {
border-color: #059669;
color: #059669;
}
</style>

View file

@ -0,0 +1,294 @@
<!--
SenderProfileForm — onboarding + settings editor for the sender profile
used on every PDF the user issues. Also carries the number-sequence state.
-->
<script lang="ts">
import { invoiceSettingsStore } from '../stores/settings.svelte';
import type { InvoiceSettings, Currency } from '../types';
import { VAT_RATES_CH, VAT_RATES_DE, CURRENCIES } from '../constants';
let settings = $state<InvoiceSettings | null>(null);
let saving = $state(false);
let savedAt = $state<string | null>(null);
$effect(() => {
invoiceSettingsStore.get().then((s) => {
settings = s;
});
});
async function save() {
if (!settings) return;
saving = true;
try {
await invoiceSettingsStore.update({
senderName: settings.senderName,
senderAddress: settings.senderAddress,
senderEmail: settings.senderEmail,
senderVatNumber: settings.senderVatNumber,
senderIban: settings.senderIban,
senderBic: settings.senderBic,
footer: settings.footer,
numberPrefix: settings.numberPrefix,
numberPadding: settings.numberPadding,
nextNumber: settings.nextNumber,
defaultCurrency: settings.defaultCurrency,
defaultVatRate: settings.defaultVatRate,
defaultDueDays: settings.defaultDueDays,
defaultTerms: settings.defaultTerms,
});
savedAt = new Date().toLocaleTimeString();
} finally {
saving = false;
}
}
const nextPreview = $derived(
settings
? `${settings.numberPrefix}${String(settings.nextNumber).padStart(settings.numberPadding, '0')}`
: '…'
);
const vatOptions = $derived(settings?.defaultCurrency === 'EUR' ? VAT_RATES_DE : VAT_RATES_CH);
</script>
{#if !settings}
<p class="loading">Lade Einstellungen …</p>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
save();
}}
class="form"
>
<section class="section">
<h3>Absender</h3>
<p class="hint">Erscheint im Kopf jeder Rechnung.</p>
<label class="field">
<span>Name *</span>
<input type="text" bind:value={settings.senderName} required />
</label>
<label class="field">
<span>Adresse *</span>
<textarea rows="3" bind:value={settings.senderAddress} required></textarea>
</label>
<div class="grid-2">
<label class="field">
<span>E-Mail *</span>
<input type="email" bind:value={settings.senderEmail} required />
</label>
<label class="field">
<span>MwSt-Nummer</span>
<input
type="text"
placeholder="CHE-123.456.789 MWST"
value={settings.senderVatNumber ?? ''}
oninput={(e) => settings && (settings.senderVatNumber = e.currentTarget.value || null)}
/>
</label>
</div>
<div class="grid-2">
<label class="field">
<span>IBAN *</span>
<input
type="text"
placeholder="CH93 0076 2011 6238 5295 7"
bind:value={settings.senderIban}
required
/>
</label>
<label class="field">
<span>BIC</span>
<input
type="text"
value={settings.senderBic ?? ''}
oninput={(e) => settings && (settings.senderBic = e.currentTarget.value || null)}
/>
</label>
</div>
<label class="field">
<span>Fußzeile</span>
<textarea
rows="2"
placeholder="Ergänzung unter jeder Rechnung (z.B. rechtliche Hinweise)"
value={settings.footer ?? ''}
oninput={(e) => settings && (settings.footer = e.currentTarget.value || null)}
></textarea>
</label>
</section>
<section class="section">
<h3>Nummernkreis</h3>
<p class="hint">Nächste Rechnung: <code>{nextPreview}</code></p>
<div class="grid-3">
<label class="field">
<span>Präfix</span>
<input type="text" bind:value={settings.numberPrefix} />
</label>
<label class="field">
<span>Stellen</span>
<input type="number" min="1" max="8" bind:value={settings.numberPadding} />
</label>
<label class="field">
<span>Nächste Nummer</span>
<input type="number" min="1" bind:value={settings.nextNumber} />
</label>
</div>
</section>
<section class="section">
<h3>Standards</h3>
<div class="grid-3">
<label class="field">
<span>Währung</span>
<select bind:value={settings.defaultCurrency}>
{#each Object.keys(CURRENCIES) as c (c)}
<option value={c}>{c}</option>
{/each}
</select>
</label>
<label class="field">
<span>MwSt.-Satz</span>
<select
value={settings.defaultVatRate}
onchange={(e) => settings && (settings.defaultVatRate = Number(e.currentTarget.value))}
>
{#each vatOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</label>
<label class="field">
<span>Zahlungsfrist (Tage)</span>
<input type="number" min="0" max="365" bind:value={settings.defaultDueDays} />
</label>
</div>
<label class="field">
<span>Standard-AGB / Zahlungsbedingungen</span>
<textarea
rows="2"
placeholder="Zahlbar innert 30 Tagen netto."
value={settings.defaultTerms ?? ''}
oninput={(e) => settings && (settings.defaultTerms = e.currentTarget.value || null)}
></textarea>
</label>
</section>
<div class="actions">
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? 'Speichert …' : 'Speichern'}
</button>
{#if savedAt}
<span class="saved">Gespeichert um {savedAt}</span>
{/if}
</div>
</form>
{/if}
<style>
.loading {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.hint code {
background: var(--color-surface-muted, #f1f5f9);
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.field > span {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.field input,
.field textarea,
.field select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
font-size: 0.95rem;
font-family: inherit;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-primary {
background: #059669;
color: white;
padding: 0.55rem 1.25rem;
border: 0;
border-radius: 0.4rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.saved {
font-size: 0.85rem;
color: #059669;
}
</style>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { STATUS_LABELS, STATUS_COLORS } from '../constants';
import type { InvoiceStatus } from '../types';
interface Props {
status: InvoiceStatus;
}
let { status }: Props = $props();
</script>
<span class="badge" style="--dot: {STATUS_COLORS[status]}">
<span class="dot"></span>
{STATUS_LABELS[status].de}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text, #0f172a);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dot);
flex-shrink: 0;
}
</style>

View file

@ -21,6 +21,22 @@ export {
INVOICE_SETTINGS_ID,
} from './constants';
export {
useAllInvoices,
useInvoiceClients,
toInvoice,
toInvoiceClient,
filterByStatus,
searchInvoices,
computeStats,
formatAmount,
} from './queries';
export { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals';
export { invoicesStore } from './stores/invoices.svelte';
export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte';
export type {
LocalInvoice,
LocalInvoiceLine,
@ -37,3 +53,6 @@ export type {
Currency,
ClientSource,
} from './types';
export type { InvoiceStats } from './queries';
export type { CreateInvoiceInput } from './stores/invoices.svelte';

View file

@ -0,0 +1,189 @@
/**
* Reactive queries and pure helpers for the Invoices module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type {
LocalInvoice,
LocalInvoiceClient,
Invoice,
InvoiceClient,
InvoiceStatus,
Currency,
} from './types';
import { INVOICE_SETTINGS_ID, CURRENCIES } from './constants';
// ─── Type Converters ─────────────────────────────────────
export function toInvoice(local: LocalInvoice): Invoice {
const now = new Date().toISOString();
return {
id: local.id,
number: local.number,
status: local.status,
clientId: local.clientId ?? null,
clientSource: local.clientSource,
clientSnapshot: local.clientSnapshot,
currency: local.currency,
issueDate: local.issueDate,
dueDate: local.dueDate,
sentAt: local.sentAt ?? null,
paidAt: local.paidAt ?? null,
lines: (local.lines ?? []).map((l) => ({
id: l.id,
title: l.title,
description: l.description ?? null,
quantity: l.quantity,
unit: l.unit ?? null,
unitPrice: l.unitPrice,
vatRate: l.vatRate,
discount: l.discount ?? null,
})),
subject: local.subject ?? null,
notes: local.notes ?? null,
terms: local.terms ?? null,
referenceNumber: local.referenceNumber ?? null,
pdfBlobKey: local.pdfBlobKey ?? null,
totals: local.totals,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toInvoiceClient(local: LocalInvoiceClient): InvoiceClient {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
address: local.address ?? null,
email: local.email ?? null,
vatNumber: local.vatNumber ?? null,
iban: local.iban ?? null,
defaultCurrency: local.defaultCurrency,
defaultDueDays: local.defaultDueDays,
notes: local.notes ?? null,
createdAt: local.createdAt ?? now,
};
}
// ─── Live Queries ────────────────────────────────────────
/**
* All invoices (not soft-deleted), decrypted, newest first by issueDate.
* The `overdue` status is computed on the fly here we don't persist it
* because it's a pure function of (status === 'sent') && (dueDate < today)
* and persisting would require a cron to flip it, creating sync churn.
*/
export function useAllInvoices() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalInvoice>('invoices').toArray();
const visible = locals.filter((i) => !i.deletedAt);
const decrypted = await decryptRecords('invoices', visible);
const today = new Date().toISOString().slice(0, 10);
return decrypted
.map((local) => {
const invoice = toInvoice(local);
if (invoice.status === 'sent' && invoice.dueDate < today) {
return { ...invoice, status: 'overdue' as InvoiceStatus };
}
return invoice;
})
.sort((a, b) => b.issueDate.localeCompare(a.issueDate));
}, [] as Invoice[]);
}
export function useInvoiceClients() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalInvoiceClient>('invoiceClients').toArray();
const visible = locals.filter((c) => !c.deletedAt);
const decrypted = await decryptRecords('invoiceClients', visible);
return decrypted.map(toInvoiceClient).sort((a, b) => a.name.localeCompare(b.name));
}, [] as InvoiceClient[]);
}
// ─── Pure Helpers ────────────────────────────────────────
export function filterByStatus(invoices: Invoice[], status: InvoiceStatus): Invoice[] {
return invoices.filter((i) => i.status === status);
}
export function searchInvoices(invoices: Invoice[], query: string): Invoice[] {
const q = query.toLowerCase();
return invoices.filter(
(i) =>
i.number.toLowerCase().includes(q) ||
i.clientSnapshot.name.toLowerCase().includes(q) ||
(i.subject?.toLowerCase().includes(q) ?? false)
);
}
// ─── Stats ───────────────────────────────────────────────
export interface InvoiceStats {
totalByStatus: Record<InvoiceStatus, number>;
/** Open amount = sent + overdue, keyed by currency because we can't sum CHF and EUR. */
openByCurrency: Record<Currency, number>;
overdueByCurrency: Record<Currency, number>;
paidYtdByCurrency: Record<Currency, number>;
invoicedYtdByCurrency: Record<Currency, number>;
}
function emptyCurrencyMap(): Record<Currency, number> {
return { CHF: 0, EUR: 0, USD: 0 };
}
export function computeStats(invoices: Invoice[], year: number): InvoiceStats {
const totalByStatus: Record<InvoiceStatus, number> = {
draft: 0,
sent: 0,
paid: 0,
overdue: 0,
void: 0,
};
const openByCurrency = emptyCurrencyMap();
const overdueByCurrency = emptyCurrencyMap();
const paidYtdByCurrency = emptyCurrencyMap();
const invoicedYtdByCurrency = emptyCurrencyMap();
const yearPrefix = String(year);
for (const inv of invoices) {
totalByStatus[inv.status]++;
if (inv.status === 'sent' || inv.status === 'overdue') {
openByCurrency[inv.currency] += inv.totals.gross;
}
if (inv.status === 'overdue') {
overdueByCurrency[inv.currency] += inv.totals.gross;
}
if (inv.issueDate.startsWith(yearPrefix) && inv.status !== 'void' && inv.status !== 'draft') {
invoicedYtdByCurrency[inv.currency] += inv.totals.gross;
}
if (inv.paidAt?.startsWith(yearPrefix)) {
paidYtdByCurrency[inv.currency] += inv.totals.gross;
}
}
return {
totalByStatus,
openByCurrency,
overdueByCurrency,
paidYtdByCurrency,
invoicedYtdByCurrency,
};
}
// ─── Formatting ──────────────────────────────────────────
/**
* Format a minor-unit integer amount as a human-readable string with the
* currency symbol. E.g. (15000, 'CHF') "CHF 150.00".
*/
export function formatAmount(minor: number, currency: Currency): string {
const { symbol, minorUnit } = CURRENCIES[currency];
return `${symbol} ${(minor / minorUnit).toFixed(2)}`;
}
// ─── Settings singleton helpers ──────────────────────────
export { INVOICE_SETTINGS_ID };

View file

@ -0,0 +1,262 @@
/**
* Invoices store mutation-only service.
*
* Reads happen via queries.ts. Writes go through this store so the
* encryption step, totals recomputation, and event emission stay
* consistent. The status machine is enforced here:
*
* draft sent (markSent) paid (markPaid)
* void (voidInvoice)
* any void (voidInvoice)
*
* "overdue" is NOT a persisted status it's derived on read from
* (status==='sent' && dueDate < today). See queries.ts.
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { invoiceTable } from '../collections';
import { computeInvoiceTotals } from '../totals';
import type {
LocalInvoice,
LocalInvoiceLine,
InvoiceClientSnapshot,
ClientSource,
Currency,
} from '../types';
import { invoiceSettingsStore } from './settings.svelte';
export interface CreateInvoiceInput {
clientId: string | null;
clientSource: ClientSource;
clientSnapshot: InvoiceClientSnapshot;
currency: Currency;
issueDate?: string;
dueDate?: string;
lines?: LocalInvoiceLine[];
subject?: string | null;
notes?: string | null;
terms?: string | null;
}
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function addDaysISO(base: string, days: number): string {
const d = new Date(`${base}T00:00:00`);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
export const invoicesStore = {
/**
* Create a new invoice in status `draft`. Pulls the next invoice number
* atomically from settings, falls back to sensible defaults for dates /
* terms / VAT if the caller didn't set them.
*/
async createInvoice(input: CreateInvoiceInput): Promise<string> {
const defaults = await invoiceSettingsStore.getDefaults();
const number = await invoiceSettingsStore.takeNextNumber();
const issueDate = input.issueDate ?? todayISO();
const dueDate = input.dueDate ?? addDaysISO(issueDate, defaults.dueDays);
const lines = input.lines ?? [];
const totals = computeInvoiceTotals(lines);
const newLocal: LocalInvoice = {
id: crypto.randomUUID(),
number,
status: 'draft',
clientId: input.clientId,
clientSource: input.clientSource,
clientSnapshot: input.clientSnapshot,
currency: input.currency,
issueDate,
dueDate,
sentAt: null,
paidAt: null,
lines,
subject: input.subject ?? null,
notes: input.notes ?? null,
terms: input.terms ?? defaults.terms,
referenceNumber: null,
pdfBlobKey: null,
totals,
};
await encryptRecord('invoices', newLocal);
await invoiceTable.add(newLocal);
emitDomainEvent('InvoiceCreated', 'invoices', 'invoices', newLocal.id, {
invoiceId: newLocal.id,
number,
gross: totals.gross,
currency: input.currency,
});
return newLocal.id;
},
/**
* Update free-form metadata on a draft invoice. Only drafts are editable
* once an invoice is sent, its content is frozen (see plan §Offene Fragen).
* To "fix" a sent invoice, void it and duplicate edit send again.
*/
async updateInvoice(
id: string,
patch: Partial<
Pick<
LocalInvoice,
| 'clientId'
| 'clientSource'
| 'clientSnapshot'
| 'currency'
| 'issueDate'
| 'dueDate'
| 'subject'
| 'notes'
| 'terms'
| 'referenceNumber'
| 'number'
>
>
) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') {
throw new Error(
'[invoices] only drafts can be edited; void and duplicate to revise a sent invoice'
);
}
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('invoices', wrapped);
await invoiceTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
/**
* Replace the full line list. Recomputes totals so denormalised totals
* stay in sync with the lines (invariant the dashboard queries depend on).
* Drafts only see updateInvoice.
*/
async updateLines(id: string, lines: LocalInvoiceLine[]) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') {
throw new Error('[invoices] only drafts can be edited');
}
const totals = computeInvoiceTotals(lines);
const patch = { lines, totals } as Record<string, unknown>;
// `lines` is in the encryption allowlist; `totals` is not. encryptRecord
// only touches allowlisted keys, so a single call is correct for both.
await encryptRecord('invoices', patch);
await invoiceTable.update(id, {
...patch,
updatedAt: new Date().toISOString(),
});
},
/**
* Transition draft sent. Freezes the invoice (no more edits) and stamps
* sentAt. The caller is expected to have already triggered the send flow
* (e.g. open mail compose with PDF attached); this is purely the state
* transition.
*/
async markSent(id: string) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status !== 'draft') return;
await invoiceTable.update(id, {
status: 'sent',
sentAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('InvoiceSent', 'invoices', 'invoices', id, {
invoiceId: id,
number: existing.number,
});
},
/**
* Transition sent/overdue paid. `paidAt` defaults to today; caller can
* override for back-dated entries (e.g. "customer paid last Friday").
*/
async markPaid(id: string, paidAt?: string) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status !== 'sent' && existing.status !== 'overdue') return;
const stamp = paidAt ?? new Date().toISOString();
await invoiceTable.update(id, {
status: 'paid',
paidAt: stamp,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('InvoicePaid', 'invoices', 'invoices', id, {
invoiceId: id,
number: existing.number,
paidAt: stamp,
});
},
/**
* Void an invoice works from any non-paid status. Preserves the row
* (sequence-number history matters for bookkeeping) but flags it
* inactive. Paid invoices can't be voided; issue a credit note instead
* (Phase 2 feature).
*/
async voidInvoice(id: string) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status === 'paid') {
throw new Error('[invoices] paid invoices cannot be voided; issue a credit note');
}
await invoiceTable.update(id, {
status: 'void',
updatedAt: new Date().toISOString(),
});
emitDomainEvent('InvoiceVoided', 'invoices', 'invoices', id, {
invoiceId: id,
number: existing.number,
});
},
/**
* Duplicate an existing invoice (typically a sent/paid one that needs
* reissuing). Produces a new draft with a fresh number, today's issue
* date, and the same lines / client / subject.
*/
async duplicate(id: string): Promise<string> {
const existing = await invoiceTable.get(id);
if (!existing) throw new Error('[invoices] duplicate: source not found');
const { decryptRecords } = await import('$lib/data/crypto');
const [decrypted] = (await decryptRecords('invoices', [existing])) as LocalInvoice[];
return this.createInvoice({
clientId: decrypted.clientId,
clientSource: decrypted.clientSource,
clientSnapshot: decrypted.clientSnapshot,
currency: decrypted.currency,
lines: decrypted.lines,
subject: decrypted.subject,
notes: decrypted.notes,
terms: decrypted.terms,
});
},
/**
* Soft-delete sets deletedAt. Only drafts and voided invoices can be
* deleted; sent/paid/overdue must be voided first (bookkeeping: you don't
* make evidence of a sent invoice disappear).
*/
async deleteInvoice(id: string) {
const existing = await invoiceTable.get(id);
if (!existing) return;
if (existing.status !== 'draft' && existing.status !== 'void') {
throw new Error('[invoices] only drafts or voided invoices can be deleted');
}
await invoiceTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('InvoiceDeleted', 'invoices', 'invoices', id, { invoiceId: id });
},
};

View file

@ -0,0 +1,167 @@
/**
* Invoice settings store singleton row per user.
*
* The settings hold the sender profile (used on every PDF) and the number
* sequence state. `takeNextNumber()` is the only path that should hand out
* invoice numbers a Dexie transaction wraps read + increment so a fast
* double-click or two parallel create flows on the same device can't
* hand out duplicate numbers.
*
* ## Transaction boundaries and crypto
*
* Web Crypto calls inside a Dexie rw transaction break the transaction
* (any non-Dexie await suspends it). Number-sequence fields (`nextNumber`,
* `numberPrefix`, `numberPadding`) are plaintext per the encryption
* registry, so we can read and write them atomically without a decrypt
* step inside the transaction.
*
* ## Sync collision (offline multi-device) known gap, M2
*
* Two devices offline both reading `nextNumber=42` both assign
* "2026-0042". Not handled in M2 because:
* (1) the MVP target is solo freelancers (one active device),
* (2) sync LWW on `nextNumber` converges on the max so subsequent
* invoices stop colliding only the two in-flight numbers dupe,
* (3) proper detection needs a scan-on-apply hook in the sync layer;
* better to solve once, cleanly, in Phase 2 than patch in M2.
* Users can manually renumber via the edit form in the meantime.
*/
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { invoiceSettingsTable } from '../collections';
import type { LocalInvoiceSettings, InvoiceSettings, Currency } from '../types';
import {
INVOICE_SETTINGS_ID,
DEFAULT_DUE_DAYS,
DEFAULT_NUMBER_PREFIX,
DEFAULT_NUMBER_PADDING,
} from '../constants';
function toInvoiceSettings(local: LocalInvoiceSettings): InvoiceSettings {
return {
id: local.id,
senderName: local.senderName ?? '',
senderAddress: local.senderAddress ?? '',
senderEmail: local.senderEmail ?? '',
senderVatNumber: local.senderVatNumber ?? null,
senderIban: local.senderIban ?? '',
senderBic: local.senderBic ?? null,
logoMediaId: local.logoMediaId ?? null,
accentColor: local.accentColor ?? '#059669',
footer: local.footer ?? null,
numberPrefix: local.numberPrefix ?? DEFAULT_NUMBER_PREFIX,
numberPadding: local.numberPadding ?? DEFAULT_NUMBER_PADDING,
nextNumber: local.nextNumber ?? 1,
defaultCurrency: local.defaultCurrency ?? 'CHF',
defaultVatRate: local.defaultVatRate ?? 8.1,
defaultDueDays: local.defaultDueDays ?? DEFAULT_DUE_DAYS,
defaultTerms: local.defaultTerms ?? null,
};
}
/**
* Get or create the singleton settings row. Must be called OUTSIDE any
* Dexie rw transaction because encryptRecord runs Web Crypto.
*/
async function ensureSettings(): Promise<LocalInvoiceSettings> {
const existing = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID);
if (existing) return existing;
const defaults: LocalInvoiceSettings = {
id: INVOICE_SETTINGS_ID,
senderName: '',
senderAddress: '',
senderEmail: '',
senderVatNumber: null,
senderIban: '',
senderBic: null,
logoMediaId: null,
accentColor: '#059669',
footer: null,
numberPrefix: DEFAULT_NUMBER_PREFIX,
numberPadding: DEFAULT_NUMBER_PADDING,
nextNumber: 1,
defaultCurrency: 'CHF',
defaultVatRate: 8.1,
defaultDueDays: DEFAULT_DUE_DAYS,
defaultTerms: null,
};
const wrapped = { ...defaults };
await encryptRecord('invoiceSettings', wrapped);
await invoiceSettingsTable.add(wrapped);
return wrapped;
}
function formatNumber(prefix: string, n: number, padding: number): string {
return `${prefix}${String(n).padStart(padding, '0')}`;
}
export const invoiceSettingsStore = {
async get(): Promise<InvoiceSettings> {
await ensureSettings();
const row = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID);
if (!row) throw new Error('[invoices] settings row missing after ensureSettings');
const [decrypted] = (await decryptRecords('invoiceSettings', [row])) as LocalInvoiceSettings[];
return toInvoiceSettings(decrypted);
},
/**
* Atomic: pull the next number, increment the counter, hand the formatted
* string back. Number-sequence fields are plaintext, so no crypto needs to
* run inside the transaction. Caller must have awaited `ensureSettings()`
* at some earlier point we don't call it here to keep the hot path fast
* and to avoid nesting an async boundary inside the rw transaction.
*/
async takeNextNumber(): Promise<string> {
await ensureSettings();
let out = '';
await db.transaction('rw', invoiceSettingsTable, async () => {
const current = await invoiceSettingsTable.get(INVOICE_SETTINGS_ID);
if (!current) throw new Error('[invoices] settings row vanished mid-transaction');
const nextN = current.nextNumber ?? 1;
const prefix = current.numberPrefix ?? DEFAULT_NUMBER_PREFIX;
const padding = current.numberPadding ?? DEFAULT_NUMBER_PADDING;
out = formatNumber(prefix, nextN, padding);
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
nextNumber: nextN + 1,
updatedAt: new Date().toISOString(),
});
});
return out;
},
async update(patch: Partial<Omit<LocalInvoiceSettings, 'id'>>) {
await ensureSettings();
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('invoiceSettings', wrapped);
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
...wrapped,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('InvoiceSettingsUpdated', 'invoices', 'invoiceSettings', INVOICE_SETTINGS_ID, {
fields: Object.keys(patch),
});
},
/** Convenience: defaults extracted for the InvoiceForm's initial state. */
async getDefaults(): Promise<{
currency: Currency;
vatRate: number;
dueDays: number;
terms: string | null;
senderIban: string;
}> {
const s = await this.get();
return {
currency: s.defaultCurrency,
vatRate: s.defaultVatRate,
dueDays: s.defaultDueDays,
terms: s.defaultTerms,
senderIban: s.senderIban,
};
},
};
export { ensureSettings };

View file

@ -0,0 +1,65 @@
/**
* Pure totals computation kept out of the store so it's trivially unit-
* testable and can be called from the form for live preview without hitting
* Dexie. Money is always integer minor units (Rappen / cents).
*
* Rounding: bankers / half-even would be stricter but the default Math.round
* (half-away-from-zero) matches what most Swiss bookkeeping tools do at the
* invoice level. Per-line tax is rounded first, then summed per rate, so
* the breakdown equals sum-of-lines exactly.
*/
import type { InvoiceLine, LocalInvoiceLine, InvoiceTotals, VatBreakdownEntry } from './types';
type AnyLine = InvoiceLine | LocalInvoiceLine;
/**
* Compute a single line's net + tax in minor units.
* net = quantity × unitPrice × (1 discount/100)
* tax = net × vatRate / 100
*
* Discount is applied before tax (standard invoicing convention: the customer
* owes tax on the discounted price, not the list price).
*/
export function computeLineTotal(line: AnyLine): { net: number; tax: number } {
const rawNet = line.quantity * line.unitPrice;
const discount = line.discount ?? 0;
const net = Math.round(rawNet * (1 - discount / 100));
const tax = Math.round((net * line.vatRate) / 100);
return { net, tax };
}
/**
* Compute the full totals envelope for an invoice. Groups tax by rate so the
* PDF footer can show a per-rate breakdown (required in CH for invoices that
* mix reduced and standard-rate positions).
*/
export function computeInvoiceTotals(lines: readonly AnyLine[]): InvoiceTotals {
const byRate = new Map<number, { base: number; tax: number }>();
let net = 0;
let vat = 0;
for (const line of lines) {
const { net: lineNet, tax: lineTax } = computeLineTotal(line);
net += lineNet;
vat += lineTax;
const bucket = byRate.get(line.vatRate) ?? { base: 0, tax: 0 };
bucket.base += lineNet;
bucket.tax += lineTax;
byRate.set(line.vatRate, bucket);
}
const vatBreakdown: VatBreakdownEntry[] = [...byRate.entries()]
.sort(([a], [b]) => a - b)
.map(([rate, { base, tax }]) => ({ rate, base, tax }));
return { net, vat, gross: net + vat, vatBreakdown };
}
/** Zero total — useful as initial state before any lines exist. */
export const EMPTY_TOTALS: InvoiceTotals = {
net: 0,
vat: 0,
gross: 0,
vatBreakdown: [],
};

View file

@ -0,0 +1,390 @@
<!--
DetailView — read-only display of an invoice with action bar.
M4 adds PDF preview on the right; M6 adds the send flow.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import StatusBadge from '../components/StatusBadge.svelte';
import { invoicesStore } from '../stores/invoices.svelte';
import { formatAmount } from '../queries';
import type { Invoice } from '../types';
import { STATUS_LABELS } from '../constants';
interface Props {
invoice: Invoice;
}
let { invoice }: Props = $props();
let actionError = $state<string | null>(null);
let busy = $state(false);
async function run(label: string, fn: () => Promise<void>) {
actionError = null;
busy = true;
try {
await fn();
} catch (e) {
actionError = e instanceof Error ? e.message : `${label} fehlgeschlagen`;
} finally {
busy = false;
}
}
async function onMarkSent() {
await run('Als versendet markieren', () => invoicesStore.markSent(invoice.id));
}
async function onMarkPaid() {
await run('Als bezahlt markieren', () => invoicesStore.markPaid(invoice.id));
}
async function onVoid() {
if (!confirm('Diese Rechnung stornieren?')) return;
await run('Stornieren', () => invoicesStore.voidInvoice(invoice.id));
}
async function onDuplicate() {
await run('Duplizieren', async () => {
const newId = await invoicesStore.duplicate(invoice.id);
goto(`/invoices/${newId}`);
});
}
async function onDelete() {
if (!confirm('Rechnung endgültig löschen?')) return;
await run('Löschen', async () => {
await invoicesStore.deleteInvoice(invoice.id);
goto('/invoices');
});
}
function onEdit() {
goto(`/invoices/${invoice.id}/edit`);
}
</script>
<article class="detail">
<header class="head">
<div class="head-left">
<div class="number">{invoice.number}</div>
<h1>{invoice.subject || 'Rechnung'}</h1>
<StatusBadge status={invoice.status} />
</div>
<div class="head-right">
<div class="amount">{formatAmount(invoice.totals.gross, invoice.currency)}</div>
<div class="due">
Fällig {invoice.dueDate}
</div>
</div>
</header>
<div class="actions">
{#if invoice.status === 'draft'}
<button class="btn" onclick={onEdit}>Bearbeiten</button>
<button class="btn btn-primary" onclick={onMarkSent} disabled={busy}>
Als versendet markieren
</button>
{/if}
{#if invoice.status === 'sent' || invoice.status === 'overdue'}
<button class="btn btn-primary" onclick={onMarkPaid} disabled={busy}>
Als bezahlt markieren
</button>
{/if}
<button class="btn" onclick={onDuplicate} disabled={busy}>Duplizieren</button>
{#if invoice.status !== 'paid' && invoice.status !== 'void'}
<button class="btn btn-danger" onclick={onVoid} disabled={busy}> Stornieren </button>
{/if}
{#if invoice.status === 'draft' || invoice.status === 'void'}
<button class="btn btn-danger" onclick={onDelete} disabled={busy}> Löschen </button>
{/if}
</div>
{#if actionError}
<div class="error">{actionError}</div>
{/if}
<section class="block">
<h3>Empfänger</h3>
<div class="client">
<div class="client-name">{invoice.clientSnapshot.name}</div>
{#if invoice.clientSnapshot.address}
<pre class="client-address">{invoice.clientSnapshot.address}</pre>
{/if}
{#if invoice.clientSnapshot.email}
<div class="client-meta">{invoice.clientSnapshot.email}</div>
{/if}
{#if invoice.clientSnapshot.vatNumber}
<div class="client-meta">MwSt-Nr.: {invoice.clientSnapshot.vatNumber}</div>
{/if}
</div>
</section>
<section class="block">
<h3>Positionen</h3>
<table class="lines">
<thead>
<tr>
<th>Position</th>
<th>Menge</th>
<th>Einzelpreis</th>
<th>MwSt.</th>
<th class="right">Netto</th>
</tr>
</thead>
<tbody>
{#each invoice.lines as line (line.id)}
<tr>
<td>
<div>{line.title}</div>
{#if line.description}<div class="muted">{line.description}</div>{/if}
</td>
<td>{line.quantity}{line.unit ? ` ${line.unit}` : ''}</td>
<td>{formatAmount(line.unitPrice, invoice.currency)}</td>
<td>{line.vatRate}%</td>
<td class="right">
{formatAmount(line.quantity * line.unitPrice, invoice.currency)}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section class="block totals-block">
<h3>Summe</h3>
<dl class="totals">
<dt>Netto</dt>
<dd>{formatAmount(invoice.totals.net, invoice.currency)}</dd>
{#each invoice.totals.vatBreakdown as b (b.rate)}
<dt>MwSt. {b.rate}%</dt>
<dd>{formatAmount(b.tax, invoice.currency)}</dd>
{/each}
<dt class="gross">Total</dt>
<dd class="gross">{formatAmount(invoice.totals.gross, invoice.currency)}</dd>
</dl>
</section>
{#if invoice.notes}
<section class="block">
<h3>Notizen</h3>
<p class="prose">{invoice.notes}</p>
</section>
{/if}
{#if invoice.terms}
<section class="block">
<h3>Zahlungsbedingungen</h3>
<p class="prose">{invoice.terms}</p>
</section>
{/if}
<footer class="meta">
<div>Status: {STATUS_LABELS[invoice.status].de}</div>
{#if invoice.sentAt}<div>Versendet: {new Date(invoice.sentAt).toLocaleString()}</div>{/if}
{#if invoice.paidAt}<div>Bezahlt: {new Date(invoice.paidAt).toLocaleString()}</div>{/if}
</footer>
</article>
<style>
.detail {
max-width: 800px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.head-left,
.head-right {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.head-right {
align-items: flex-end;
}
.number {
font-variant-numeric: tabular-nums;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.amount {
font-size: 1.75rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.due {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
background: white;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #059669;
color: white;
border-color: #059669;
}
.btn-danger {
color: #b91c1c;
border-color: #fecaca;
}
.error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 0.4rem;
font-size: 0.9rem;
}
.block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-muted, #64748b);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.client {
padding: 0.75rem 1rem;
background: var(--color-surface-muted, #f8fafc);
border-radius: 0.4rem;
}
.client-name {
font-weight: 500;
}
.client-address {
margin: 0.25rem 0;
font-family: inherit;
white-space: pre-wrap;
font-size: 0.9rem;
}
.client-meta {
font-size: 0.85rem;
color: var(--color-text-muted, #64748b);
}
.lines {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.lines th,
.lines td {
padding: 0.5rem 0.5rem;
border-bottom: 1px solid var(--color-border, #e2e8f0);
text-align: left;
}
.lines th {
font-weight: 500;
color: var(--color-text-muted, #64748b);
font-size: 0.8rem;
}
.lines .right {
text-align: right;
font-variant-numeric: tabular-nums;
}
.lines .muted {
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
}
.totals-block {
align-items: flex-end;
}
.totals {
display: grid;
grid-template-columns: auto auto;
gap: 0.25rem 2rem;
margin: 0;
font-variant-numeric: tabular-nums;
}
.totals dt,
.totals dd {
margin: 0;
}
.totals dd {
text-align: right;
}
.totals .gross {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #e2e8f0);
font-weight: 600;
font-size: 1.05rem;
}
.prose {
white-space: pre-wrap;
margin: 0;
line-height: 1.5;
}
.meta {
display: flex;
gap: 1.5rem;
font-size: 0.8rem;
color: var(--color-text-muted, #64748b);
border-top: 1px solid var(--color-border, #e2e8f0);
padding-top: 1rem;
}
</style>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { page } from '$app/stores';
import { useAllInvoices } from '$lib/modules/invoices/queries';
import DetailView from '$lib/modules/invoices/views/DetailView.svelte';
const invoices$ = useAllInvoices();
const invoices = $derived(invoices$.value ?? []);
const id = $derived($page.params.id);
const invoice = $derived(invoices.find((i) => i.id === id));
</script>
<svelte:head>
<title>{invoice?.number ?? 'Rechnung'} - Mana</title>
</svelte:head>
{#if invoice}
<DetailView {invoice} />
{:else if invoices$.value !== undefined}
<div class="not-found">
<p>Rechnung nicht gefunden.</p>
<a href="/invoices">Zurück zur Übersicht</a>
</div>
{:else}
<div class="loading">Lädt …</div>
{/if}
<style>
.not-found,
.loading {
padding: 3rem 1rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
</style>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { page } from '$app/stores';
import { useAllInvoices } from '$lib/modules/invoices/queries';
import InvoiceForm from '$lib/modules/invoices/components/InvoiceForm.svelte';
const invoices$ = useAllInvoices();
const invoices = $derived(invoices$.value ?? []);
const id = $derived($page.params.id);
const invoice = $derived(invoices.find((i) => i.id === id));
const canEdit = $derived(invoice?.status === 'draft');
</script>
<svelte:head>
<title>Rechnung bearbeiten - Mana</title>
</svelte:head>
<div class="page">
{#if !invoice && invoices$.value !== undefined}
<div class="not-found">
<p>Rechnung nicht gefunden.</p>
<a href="/invoices">Zurück zur Übersicht</a>
</div>
{:else if invoice && !canEdit}
<div class="not-editable">
<h2>Rechnung kann nicht bearbeitet werden</h2>
<p>
Nur Entwürfe sind editierbar. Diese Rechnung hat Status
<strong>{invoice.status}</strong>. Um eine versendete Rechnung zu ändern, storniere sie und
dupliziere sie als neuen Entwurf.
</p>
<a href="/invoices/{invoice.id}">Zurück zum Detail</a>
</div>
{:else if invoice}
<header class="head">
<h1>Rechnung {invoice.number} bearbeiten</h1>
</header>
<InvoiceForm existing={invoice} />
{:else}
<div class="loading">Lädt …</div>
{/if}
</div>
<style>
.page {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.head {
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.not-found,
.loading,
.not-editable {
padding: 3rem 1rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.not-editable h2 {
color: var(--color-text, #0f172a);
}
.not-editable p {
max-width: 40ch;
margin: 0.5rem auto;
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import InvoiceForm from '$lib/modules/invoices/components/InvoiceForm.svelte';
</script>
<svelte:head>
<title>Neue Rechnung - Mana</title>
</svelte:head>
<div class="page">
<header class="head">
<h1>Neue Rechnung</h1>
<p class="subtitle">Entwurf erstellen — wird nach dem Speichern noch nicht versendet.</p>
</header>
<InvoiceForm />
</div>
<style>
.page {
max-width: 960px;
margin: 0 auto;
padding: 1.5rem;
}
.head {
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import SenderProfileForm from '$lib/modules/invoices/components/SenderProfileForm.svelte';
</script>
<svelte:head>
<title>Rechnungs-Einstellungen - Mana</title>
</svelte:head>
<div class="page">
<header class="head">
<h1>Rechnungs-Einstellungen</h1>
<p class="subtitle">Absender, Nummernkreis und Standards für alle neuen Rechnungen.</p>
</header>
<SenderProfileForm />
</div>
<style>
.page {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem;
}
.head {
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.9rem;
}
</style>