mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
248137ec43
commit
8d00ee0697
16 changed files with 2704 additions and 61 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
189
apps/mana/apps/web/src/lib/modules/invoices/queries.ts
Normal file
189
apps/mana/apps/web/src/lib/modules/invoices/queries.ts
Normal 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 };
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
65
apps/mana/apps/web/src/lib/modules/invoices/totals.ts
Normal file
65
apps/mana/apps/web/src/lib/modules/invoices/totals.ts
Normal 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: [],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue