mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(invoices): M1 skeleton — module registration + empty ListView
New outbound-finance module that issues invoices to clients. M1 scope: - types, constants, collections with demo seed (not auto-loaded) - module.config registered in module-registry - Dexie v27 with invoices / invoiceClients / invoiceSettings tables - encryption registry entries for all three tables (type-safe via entry<T>) - app entry (requiredTier: alpha) + gradient icon (emerald→teal, QR corner) - route /invoices mounts ListView with empty state Money stored as integers in minor units (Rappen/cents) to avoid float drift. Totals kept plaintext for liveQuery aggregation; lines encrypted as a whole array so titles ride alongside. Settings is a singleton with stable sentinel id so sync dedupes on it. Plan: docs/plans/invoices-module.md. Next: M2 CRUD + number generator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
11f768b8e5
commit
2cf89ce26a
12 changed files with 666 additions and 0 deletions
|
|
@ -78,6 +78,11 @@ import type { LocalNote } from '../../modules/notes/types';
|
|||
import type { LocalDream, LocalDreamSymbol } from '../../modules/dreams/types';
|
||||
import type { LocalJournalEntry } from '../../modules/journal/types';
|
||||
import type { LocalMemo } from '../../modules/memoro/types';
|
||||
import type {
|
||||
LocalInvoice,
|
||||
LocalInvoiceClient,
|
||||
LocalInvoiceSettings,
|
||||
} from '../../modules/invoices/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -580,6 +585,54 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
enabled: true,
|
||||
fields: ['title', 'originalTitle', 'creators', 'review', 'tags'],
|
||||
},
|
||||
|
||||
// ─── Invoices ────────────────────────────────────────────
|
||||
// Outbound finance. Sensitive surface is non-trivial: client name and
|
||||
// address, the free-text subject/notes/terms, and the line items
|
||||
// themselves (title/description carry service names or project codes
|
||||
// that leak who the user works for + what they're paid to do).
|
||||
//
|
||||
// Plaintext (intentional):
|
||||
// - number, status, clientId, clientSource, currency, issueDate,
|
||||
// dueDate, sentAt, paidAt, referenceNumber, pdfBlobKey: all
|
||||
// structural, used for indexing/filter/aggregation.
|
||||
// - totals (net/vat/gross): kept plaintext so the dashboard's
|
||||
// "open" and "overdue" sums can be computed in a liveQuery
|
||||
// without decrypting every row. The sum-across-customers is
|
||||
// business-useful info the user sees at a glance; encrypting it
|
||||
// would defeat the local-first reactive layer.
|
||||
// - lines is encrypted as a whole blob (see below): the numeric
|
||||
// subfields would be plaintext-eligible, but serialising the
|
||||
// whole array in one pass keeps the encryption boundary simple
|
||||
// and lets per-line titles travel encrypted alongside.
|
||||
invoices: entry<LocalInvoice>(['clientSnapshot', 'subject', 'notes', 'terms', 'lines']),
|
||||
|
||||
// Optional per-user client book. Everything user-typed is sensitive —
|
||||
// name, postal address, email, VAT number, IBAN, free-text notes.
|
||||
// defaultCurrency / defaultDueDays stay plaintext (structural enums).
|
||||
invoiceClients: entry<LocalInvoiceClient>([
|
||||
'name',
|
||||
'address',
|
||||
'email',
|
||||
'vatNumber',
|
||||
'iban',
|
||||
'notes',
|
||||
]),
|
||||
|
||||
// Singleton sender profile. The user's legal address + IBAN live here
|
||||
// and are the most sensitive fields in the module (appear on every PDF
|
||||
// the user issues). logoMediaId / accentColor / number sequence state
|
||||
// are plaintext — structural, no privacy value.
|
||||
invoiceSettings: entry<LocalInvoiceSettings>([
|
||||
'senderName',
|
||||
'senderAddress',
|
||||
'senderEmail',
|
||||
'senderVatNumber',
|
||||
'senderIban',
|
||||
'senderBic',
|
||||
'footer',
|
||||
'defaultTerms',
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -592,6 +592,21 @@ db.version(26).stores({
|
|||
libraryEntries: 'id, kind, status, completedAt, isFavorite',
|
||||
});
|
||||
|
||||
// v27 — Invoices module: outbound finance (issuing invoices to clients).
|
||||
// See docs/plans/invoices-module.md. Three tables:
|
||||
// - invoices: the invoice records (status/dueDate/clientId indexed for
|
||||
// the "overdue per client" + "open per status" queries that drive the
|
||||
// ListView + dashboard widgets).
|
||||
// - invoiceClients: optional per-user client book; only userId needed
|
||||
// since listing is always scoped to the current user.
|
||||
// - invoiceSettings: singleton sender profile (one row per user, id is
|
||||
// the stable sentinel INVOICE_SETTINGS_ID so sync dedupes on it).
|
||||
db.version(27).stores({
|
||||
invoices: 'id, number, status, clientId, issueDate, dueDate',
|
||||
invoiceClients: 'id',
|
||||
invoiceSettings: 'id',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
|
|||
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||
import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
|
|
@ -155,6 +156,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
quizModuleConfig,
|
||||
profileModuleConfig,
|
||||
libraryModuleConfig,
|
||||
invoicesModuleConfig,
|
||||
wetterModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
|
|
|||
178
apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte
Normal file
178
apps/mana/apps/web/src/lib/modules/invoices/ListView.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<!--
|
||||
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.
|
||||
-->
|
||||
<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';
|
||||
|
||||
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);
|
||||
|
||||
function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string {
|
||||
const { symbol, minorUnit } = CURRENCIES[currency];
|
||||
const value = minor / minorUnit;
|
||||
return `${symbol} ${value.toFixed(2)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="invoices-shell">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>Rechnungen</h1>
|
||||
<p class="subtitle">Outbound-Finance — Rechnungen stellen und verfolgen</p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" disabled title="M2">+ Neu</button>
|
||||
</header>
|
||||
|
||||
{#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>
|
||||
</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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invoices-shell {
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 0;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #0f172a);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 1rem !important;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem 1fr auto 7rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.client {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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>
|
||||
64
apps/mana/apps/web/src/lib/modules/invoices/collections.ts
Normal file
64
apps/mana/apps/web/src/lib/modules/invoices/collections.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Invoices module — Dexie accessors and guest seed.
|
||||
*
|
||||
* Tables:
|
||||
* - `invoices` — the invoice records themselves
|
||||
* - `invoiceClients` — optional per-user client book
|
||||
* - `invoiceSettings` — singleton sender profile + number sequence
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalInvoice, LocalInvoiceClient, LocalInvoiceSettings } from './types';
|
||||
|
||||
export const invoiceTable = db.table<LocalInvoice>('invoices');
|
||||
export const invoiceClientTable = db.table<LocalInvoiceClient>('invoiceClients');
|
||||
export const invoiceSettingsTable = db.table<LocalInvoiceSettings>('invoiceSettings');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
// One draft invoice so first-run users immediately see what the module is
|
||||
// for. Amounts stored in Rappen / cents (integer) — 150.00 CHF → 15000.
|
||||
|
||||
export const INVOICES_GUEST_SEED = {
|
||||
invoices: [
|
||||
{
|
||||
id: 'demo-invoice-draft',
|
||||
number: `${new Date().getFullYear()}-0001`,
|
||||
status: 'draft' as const,
|
||||
clientId: null,
|
||||
clientSource: 'invoice-client' as const,
|
||||
clientSnapshot: {
|
||||
name: 'Muster Kunde AG',
|
||||
address: 'Bahnhofstrasse 1\n8000 Zürich',
|
||||
email: 'kontakt@muster-kunde.ch',
|
||||
},
|
||||
currency: 'CHF' as const,
|
||||
issueDate: new Date().toISOString().slice(0, 10),
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
sentAt: null,
|
||||
paidAt: null,
|
||||
lines: [
|
||||
{
|
||||
id: 'demo-line-1',
|
||||
title: 'Beratung',
|
||||
description: null,
|
||||
quantity: 4,
|
||||
unit: 'Std',
|
||||
unitPrice: 15000, // 150.00 CHF
|
||||
vatRate: 8.1,
|
||||
discount: null,
|
||||
},
|
||||
],
|
||||
subject: 'Beratungsleistung',
|
||||
notes: null,
|
||||
terms: 'Zahlbar innert 30 Tagen netto.',
|
||||
referenceNumber: null,
|
||||
pdfBlobKey: null,
|
||||
totals: {
|
||||
net: 60000,
|
||||
vat: 4860,
|
||||
gross: 64860,
|
||||
vatBreakdown: [{ rate: 8.1, base: 60000, tax: 4860 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
47
apps/mana/apps/web/src/lib/modules/invoices/constants.ts
Normal file
47
apps/mana/apps/web/src/lib/modules/invoices/constants.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { Currency, InvoiceStatus } from './types';
|
||||
|
||||
export const STATUS_LABELS: Record<InvoiceStatus, { de: string; en: string }> = {
|
||||
draft: { de: 'Entwurf', en: 'Draft' },
|
||||
sent: { de: 'Versendet', en: 'Sent' },
|
||||
paid: { de: 'Bezahlt', en: 'Paid' },
|
||||
overdue: { de: 'Überfällig', en: 'Overdue' },
|
||||
void: { de: 'Storniert', en: 'Void' },
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<InvoiceStatus, string> = {
|
||||
draft: '#64748b',
|
||||
sent: '#3b82f6',
|
||||
paid: '#22c55e',
|
||||
overdue: '#ef4444',
|
||||
void: '#94a3b8',
|
||||
};
|
||||
|
||||
/** Swiss VAT rates as of 2024 (MwSt-Satz). */
|
||||
export const VAT_RATES_CH = [
|
||||
{ value: 0, label: '0% (ausgenommen)' },
|
||||
{ value: 2.6, label: '2.6% (reduziert)' },
|
||||
{ value: 3.8, label: '3.8% (Beherbergung)' },
|
||||
{ value: 8.1, label: '8.1% (Normalsatz)' },
|
||||
];
|
||||
|
||||
/** German VAT rates. */
|
||||
export const VAT_RATES_DE = [
|
||||
{ value: 0, label: '0%' },
|
||||
{ value: 7, label: '7% (ermäßigt)' },
|
||||
{ value: 19, label: '19% (Regelsatz)' },
|
||||
];
|
||||
|
||||
export const CURRENCIES: Record<Currency, { symbol: string; minorUnit: number }> = {
|
||||
CHF: { symbol: 'CHF', minorUnit: 100 },
|
||||
EUR: { symbol: '€', minorUnit: 100 },
|
||||
USD: { symbol: '$', minorUnit: 100 },
|
||||
};
|
||||
|
||||
export const DEFAULT_DUE_DAYS = 30;
|
||||
|
||||
export const DEFAULT_NUMBER_PREFIX = `${new Date().getFullYear()}-`;
|
||||
export const DEFAULT_NUMBER_PADDING = 4;
|
||||
|
||||
/** Sentinel id for the singleton settings row. Stable across devices so the
|
||||
* sync engine dedupes on it instead of fighting over two parallel rows. */
|
||||
export const INVOICE_SETTINGS_ID = 'invoice-settings';
|
||||
39
apps/mana/apps/web/src/lib/modules/invoices/index.ts
Normal file
39
apps/mana/apps/web/src/lib/modules/invoices/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Invoices module — barrel exports.
|
||||
*/
|
||||
|
||||
export {
|
||||
invoiceTable,
|
||||
invoiceClientTable,
|
||||
invoiceSettingsTable,
|
||||
INVOICES_GUEST_SEED,
|
||||
} from './collections';
|
||||
|
||||
export {
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
VAT_RATES_CH,
|
||||
VAT_RATES_DE,
|
||||
CURRENCIES,
|
||||
DEFAULT_DUE_DAYS,
|
||||
DEFAULT_NUMBER_PREFIX,
|
||||
DEFAULT_NUMBER_PADDING,
|
||||
INVOICE_SETTINGS_ID,
|
||||
} from './constants';
|
||||
|
||||
export type {
|
||||
LocalInvoice,
|
||||
LocalInvoiceLine,
|
||||
LocalInvoiceClient,
|
||||
LocalInvoiceSettings,
|
||||
Invoice,
|
||||
InvoiceLine,
|
||||
InvoiceClient,
|
||||
InvoiceSettings,
|
||||
InvoiceStatus,
|
||||
InvoiceTotals,
|
||||
InvoiceClientSnapshot,
|
||||
VatBreakdownEntry,
|
||||
Currency,
|
||||
ClientSource,
|
||||
} from './types';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const invoicesModuleConfig: ModuleConfig = {
|
||||
appId: 'invoices',
|
||||
tables: [{ name: 'invoices' }, { name: 'invoiceClients' }, { name: 'invoiceSettings' }],
|
||||
};
|
||||
231
apps/mana/apps/web/src/lib/modules/invoices/types.ts
Normal file
231
apps/mana/apps/web/src/lib/modules/invoices/types.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Invoices module types.
|
||||
*
|
||||
* Outbound finance: the user issues invoices to their clients. Plan:
|
||||
* `docs/plans/invoices-module.md`. Sibling to `finance` (inbound tracking),
|
||||
* not a replacement.
|
||||
*
|
||||
* Money is stored as integers in the currency's smallest unit (Rappen / cents)
|
||||
* to avoid floating-point drift on totals. Conversion to display format
|
||||
* happens in the view layer.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
// ─── Discriminators & Enums ──────────────────────────────
|
||||
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void';
|
||||
|
||||
export type Currency = 'CHF' | 'EUR' | 'USD';
|
||||
|
||||
/**
|
||||
* Which table the `clientId` FK points into. Set at create time so the
|
||||
* resolver can look up the snapshot without having to probe both tables.
|
||||
*/
|
||||
export type ClientSource = 'contact' | 'invoice-client';
|
||||
|
||||
// ─── Line Items ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single invoice line. `id` is a client-generated UUID used as the stable
|
||||
* key for reorder operations — not a Dexie row id (lines live inline on the
|
||||
* invoice for atomicity of edits and serialisation through the encryption
|
||||
* boundary).
|
||||
*/
|
||||
export interface LocalInvoiceLine {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
quantity: number;
|
||||
unit?: string | null;
|
||||
/** In the currency's smallest unit (Rappen / cents), integer. */
|
||||
unitPrice: number;
|
||||
/** Percent; e.g. 8.1 for Swiss standard rate. */
|
||||
vatRate: number;
|
||||
/** Percent; 0..100. null/undefined means no discount. */
|
||||
discount?: number | null;
|
||||
}
|
||||
|
||||
// ─── Totals ──────────────────────────────────────────────
|
||||
|
||||
export interface VatBreakdownEntry {
|
||||
rate: number;
|
||||
base: number;
|
||||
tax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Denormalised totals — recomputed on every update. Kept plaintext so the
|
||||
* dashboard widgets / overdue-sum queries don't have to decrypt.
|
||||
*/
|
||||
export interface InvoiceTotals {
|
||||
net: number;
|
||||
vat: number;
|
||||
gross: number;
|
||||
vatBreakdown: VatBreakdownEntry[];
|
||||
}
|
||||
|
||||
// ─── Client Snapshot ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Frozen at send time so that later edits to the Contact / InvoiceClient
|
||||
* don't retroactively change a legally binding, already-sent invoice.
|
||||
* See §"Offene Fragen" in the plan for the edit-after-send policy (only
|
||||
* drafts are editable for this reason).
|
||||
*/
|
||||
export interface InvoiceClientSnapshot {
|
||||
name: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
vatNumber?: string;
|
||||
}
|
||||
|
||||
// ─── Local Record — Invoice (Dexie) ──────────────────────
|
||||
|
||||
export interface LocalInvoice extends BaseRecord {
|
||||
/** Rendered invoice number, e.g. "2026-0042". Generated from settings. */
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
clientId: string | null;
|
||||
clientSource: ClientSource;
|
||||
clientSnapshot: InvoiceClientSnapshot;
|
||||
currency: Currency;
|
||||
/** YYYY-MM-DD */
|
||||
issueDate: string;
|
||||
/** YYYY-MM-DD */
|
||||
dueDate: string;
|
||||
/** ISO timestamp — filled when status transitions to sent. */
|
||||
sentAt?: string | null;
|
||||
/** ISO timestamp — filled when status transitions to paid. */
|
||||
paidAt?: string | null;
|
||||
lines: LocalInvoiceLine[];
|
||||
subject?: string | null;
|
||||
notes?: string | null;
|
||||
terms?: string | null;
|
||||
/** QR-Referenz (CH: 27 digits, mod-10 check) or SCOR ISO 11649. */
|
||||
referenceNumber?: string | null;
|
||||
/** uload media id of the most recently rendered PDF (cache). */
|
||||
pdfBlobKey?: string | null;
|
||||
totals: InvoiceTotals;
|
||||
}
|
||||
|
||||
// ─── Local Record — Client Book (Dexie) ──────────────────
|
||||
|
||||
/**
|
||||
* Optional separate client book for invoice-specific data that doesn't
|
||||
* belong in the main contacts table (IBAN for SEPA, default due days,
|
||||
* default currency). Users can also just invoice contacts directly —
|
||||
* this table is opt-in.
|
||||
*/
|
||||
export interface LocalInvoiceClient extends BaseRecord {
|
||||
name: string;
|
||||
address?: string | null;
|
||||
email?: string | null;
|
||||
vatNumber?: string | null;
|
||||
iban?: string | null;
|
||||
defaultCurrency: Currency;
|
||||
defaultDueDays: number;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ─── Local Record — Settings (Dexie, singleton per user) ─
|
||||
|
||||
/**
|
||||
* User-scoped sender profile + number sequence state. There is exactly one
|
||||
* row per user; `id` is a stable sentinel (`'invoice-settings'`) and the
|
||||
* Dexie hook stamps userId as usual.
|
||||
*/
|
||||
export interface LocalInvoiceSettings extends BaseRecord {
|
||||
// Sender profile
|
||||
senderName: string;
|
||||
senderAddress: string;
|
||||
senderEmail: string;
|
||||
senderVatNumber?: string | null;
|
||||
senderIban: string;
|
||||
senderBic?: string | null;
|
||||
|
||||
// Branding
|
||||
logoMediaId?: string | null;
|
||||
accentColor: string;
|
||||
footer?: string | null;
|
||||
|
||||
// Number sequence
|
||||
numberPrefix: string;
|
||||
numberPadding: number;
|
||||
nextNumber: number;
|
||||
|
||||
// Defaults
|
||||
defaultCurrency: Currency;
|
||||
defaultVatRate: number;
|
||||
defaultDueDays: number;
|
||||
defaultTerms?: string | null;
|
||||
}
|
||||
|
||||
// ─── Domain Types (plaintext, for UI) ────────────────────
|
||||
|
||||
export interface InvoiceLine {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unit: string | null;
|
||||
unitPrice: number;
|
||||
vatRate: number;
|
||||
discount: number | null;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
clientId: string | null;
|
||||
clientSource: ClientSource;
|
||||
clientSnapshot: InvoiceClientSnapshot;
|
||||
currency: Currency;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
sentAt: string | null;
|
||||
paidAt: string | null;
|
||||
lines: InvoiceLine[];
|
||||
subject: string | null;
|
||||
notes: string | null;
|
||||
terms: string | null;
|
||||
referenceNumber: string | null;
|
||||
pdfBlobKey: string | null;
|
||||
totals: InvoiceTotals;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InvoiceClient {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
vatNumber: string | null;
|
||||
iban: string | null;
|
||||
defaultCurrency: Currency;
|
||||
defaultDueDays: number;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InvoiceSettings {
|
||||
id: string;
|
||||
senderName: string;
|
||||
senderAddress: string;
|
||||
senderEmail: string;
|
||||
senderVatNumber: string | null;
|
||||
senderIban: string;
|
||||
senderBic: string | null;
|
||||
logoMediaId: string | null;
|
||||
accentColor: string;
|
||||
footer: string | null;
|
||||
numberPrefix: string;
|
||||
numberPadding: number;
|
||||
nextNumber: number;
|
||||
defaultCurrency: Currency;
|
||||
defaultVatRate: number;
|
||||
defaultDueDays: number;
|
||||
defaultTerms: string | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/invoices/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rechnungen - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView />
|
||||
|
|
@ -231,6 +231,11 @@ export const APP_ICONS = {
|
|||
// gradient sits next to music/photos/picture in the Kreativität & Medien row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="lb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#a855f7"/><stop offset="100%" style="stop-color:#d946ef"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#lb)"/><rect x="22" y="28" width="10" height="44" rx="2" fill="white" fill-opacity="0.95"/><rect x="34" y="24" width="10" height="48" rx="2" fill="white" fill-opacity="0.8"/><rect x="46" y="30" width="10" height="42" rx="2" fill="white" fill-opacity="0.95"/><rect x="58" y="28" width="22" height="44" rx="3" fill="white"/><rect x="62" y="34" width="4" height="4" fill="#a855f7"/><rect x="72" y="34" width="4" height="4" fill="#a855f7"/><rect x="62" y="44" width="4" height="4" fill="#a855f7"/><rect x="72" y="44" width="4" height="4" fill="#a855f7"/><rect x="62" y="54" width="4" height="4" fill="#a855f7"/><rect x="72" y="54" width="4" height="4" fill="#a855f7"/><rect x="62" y="64" width="4" height="4" fill="#a855f7"/><rect x="72" y="64" width="4" height="4" fill="#a855f7"/><rect x="20" y="74" width="62" height="4" rx="2" fill="white" fill-opacity="0.5"/></svg>`
|
||||
),
|
||||
invoices: svgToDataUrl(
|
||||
// Document with a QR-code corner (CH QR-Bill) + a diagonal amount line.
|
||||
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'invoices',
|
||||
name: 'Rechnungen',
|
||||
description: {
|
||||
de: 'Rechnungen stellen mit QR-Bill',
|
||||
en: 'Issue invoices with Swiss QR-Bill',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Rechnungen an Kunden stellen — mit PDF-Export, Schweizer QR-Rechnung, Mehrwertsteuer und Zahlungsverfolgung. Ergänzt das Finance-Modul um die Outbound-Seite.',
|
||||
en: 'Issue invoices to clients — with PDF export, Swiss QR-Bill, VAT handling and payment tracking. Adds the outbound side to the Finance module.',
|
||||
},
|
||||
icon: APP_ICONS.invoices,
|
||||
color: '#059669',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'alpha',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue