feat(invoices): M7 dashboard widget + tests + plan status

Adds the missing bits that turn M1–M6 into a coherent shippable
product rather than a pile of commits.

Dashboard widget (M7)
- InvoicesOpenWidget.svelte: open + overdue totals in the primary
  currency, top-3 oldest overdue with "X Tage überfällig" under each,
  empty-state CTA for first-time users
- Registered as `invoices-open` in WIDGET_REGISTRY and the component
  map. Default size medium, no requiredBackend (local-first, no API)
- Fixed pre-existing test gap: validBackends list was missing 'body'
  (body-stats widget has been failing silently) — added so the check
  protects against drift for real

Tests (45 total, all green)
- totals.test.ts (9): computeLineTotal with discount+vat, grouping
  invariant (breakdown sums == invoice totals), rounding edges
- pdf/qr-bill.test.ts (17): generateSCORReference stability +
  spec-validity via swissqrbill's own isSCORReferenceValid, buildQRBillData
  eligibility gates (currency, IBAN, address, amount), CH + DE address
  parser paths, referenceNumber-preferred-over-regen invariant
- mail-template.test.ts (12): subject/body composition (with/without
  subject, CHF vs EUR QR-hint, empty recipient fallback), mailto
  spaces-as-%20 patch, looksLikeEmail edge cases

Plan (docs/plans/invoices-module.md)
- Updated with commit SHAs per milestone, testing status, and the
  explicit list of open items (Logo-Upload, AI-Tools, sync collision,
  structured addresses, finance cross-link, camt bankabgleich) so the
  next coder knows exactly what's parked where

Unresolved: browser smoke test couldn't run — SSR is broken for all
module routes in the current tree (pre-existing, likely from the
parallel Spaces refactor; /library, /todo, /contacts all return 500
the same way). Unit tests + clean bundle build (M4) + type-check are
the coverage we have.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 16:38:18 +02:00
parent 2d15684ed4
commit 2ee3a1a93a
8 changed files with 643 additions and 3 deletions

View file

@ -33,6 +33,7 @@ import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.s
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
@ -62,4 +63,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
period: PeriodWidget,
'news-unread': NewsUnreadWidget,
'body-stats': BodyStatsWidget,
'invoices-open': InvoicesOpenWidget,
};

View file

@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import { buildInvoiceMailDraft, mailDraftToMailto, looksLikeEmail } from './mail-template';
import type { Invoice, InvoiceSettings } from './types';
const settings: InvoiceSettings = {
id: 'invoice-settings',
senderName: 'Till',
senderAddress: 'Bahnhofstrasse 1\n8000 Zürich',
senderEmail: 'till@example.ch',
senderVatNumber: null,
senderIban: 'CH9300762011623852957',
senderBic: null,
logoMediaId: null,
accentColor: '#059669',
footer: null,
numberPrefix: '2026-',
numberPadding: 4,
nextNumber: 1,
defaultCurrency: 'CHF',
defaultVatRate: 8.1,
defaultDueDays: 30,
defaultTerms: null,
};
function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: 'i',
number: '2026-0001',
status: 'draft',
clientId: null,
clientSource: 'invoice-client',
clientSnapshot: {
name: 'Kunde GmbH',
address: 'Seefeldstrasse 5\n8008 Zürich',
email: 'kunde@example.ch',
},
currency: 'CHF',
issueDate: '2026-04-20',
dueDate: '2026-05-20',
sentAt: null,
paidAt: null,
lines: [],
subject: null,
notes: null,
terms: null,
referenceNumber: null,
pdfBlobKey: null,
totals: {
net: 15000,
vat: 1215,
gross: 16215,
vatBreakdown: [{ rate: 8.1, base: 15000, tax: 1215 }],
},
createdAt: '',
updatedAt: '',
...overrides,
};
}
describe('buildInvoiceMailDraft', () => {
it('prefills recipient from the frozen snapshot', () => {
const draft = buildInvoiceMailDraft(makeInvoice(), settings);
expect(draft.to).toBe('kunde@example.ch');
});
it('empty recipient when snapshot has no email', () => {
const invoice = makeInvoice({
clientSnapshot: { name: 'Kunde', address: 'foo\n8000 bar' },
});
const draft = buildInvoiceMailDraft(invoice, settings);
expect(draft.to).toBe('');
});
it('includes the invoice number in the subject', () => {
const draft = buildInvoiceMailDraft(makeInvoice(), settings);
expect(draft.subject).toContain('2026-0001');
});
it('appends subject when present', () => {
const draft = buildInvoiceMailDraft(makeInvoice({ subject: 'Beratung April' }), settings);
expect(draft.subject).toContain('Beratung April');
expect(draft.subject).toBe('Rechnung 2026-0001 — Beratung April');
});
it('omits the em-dash when subject is blank', () => {
const draft = buildInvoiceMailDraft(makeInvoice({ subject: null }), settings);
expect(draft.subject).toBe('Rechnung 2026-0001');
});
it('mentions QR-Einzahlungsschein only for CHF', () => {
const chf = buildInvoiceMailDraft(makeInvoice({ currency: 'CHF' }), settings);
expect(chf.body).toContain('QR-Einzahlungsschein');
const eur = buildInvoiceMailDraft(makeInvoice({ currency: 'EUR' }), settings);
expect(eur.body).not.toContain('QR-Einzahlungsschein');
});
it('signs off with sender name', () => {
const draft = buildInvoiceMailDraft(makeInvoice(), settings);
expect(draft.body).toContain('Till');
});
it('falls back to a generic salutation when no client name', () => {
const invoice = makeInvoice({
clientSnapshot: { name: '', address: '', email: '' },
});
const draft = buildInvoiceMailDraft(invoice, settings);
expect(draft.body).toContain('Geehrte Damen und Herren');
});
it('includes gross amount and due date', () => {
const draft = buildInvoiceMailDraft(makeInvoice(), settings);
expect(draft.body).toContain('2026-05-20');
expect(draft.body).toContain('CHF 162.15');
});
});
describe('mailDraftToMailto', () => {
it('starts with mailto: and encodes the recipient', () => {
const url = mailDraftToMailto({ to: 'foo@bar.ch', subject: 's', body: 'b' });
expect(url.startsWith('mailto:')).toBe(true);
expect(url).toContain('foo%40bar.ch');
});
it('encodes spaces as %20 (not `+`) for cross-client compatibility', () => {
// URLSearchParams defaults to `+` which macOS Mail / Outlook take
// literally — our patch ensures Apple Mail / iOS render "Hello World"
// and not "Hello+World" in the subject field.
const url = mailDraftToMailto({ to: 'x@y.z', subject: 'Hello World', body: 'a b c' });
expect(url).not.toContain('+');
expect(url).toContain('Hello%20World');
});
it('preserves newlines in the body', () => {
const url = mailDraftToMailto({ to: 'x@y.z', subject: 's', body: 'line1\nline2' });
expect(url).toContain('line1%0Aline2');
});
});
describe('looksLikeEmail', () => {
it.each([
['kontakt@memoro.ai', true],
['a@b.co', true],
['spaces inside@bad.com', false],
['no-at.com', false],
['trailing@', false],
['', false],
[' padded@example.com ', true], // allows leading/trailing whitespace (trimmed)
])('%s → %s', (input, expected) => {
expect(looksLikeEmail(input)).toBe(expected);
});
});

View file

@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import { isSCORReferenceValid } from 'swissqrbill/utils';
import { generateSCORReference, buildQRBillData, QRBillError } from './qr-bill';
import type { Invoice, InvoiceSettings } from '../types';
// ─── Factories ─────────────────────────────────────────
function makeSettings(overrides: Partial<InvoiceSettings> = {}): InvoiceSettings {
return {
id: 'invoice-settings',
senderName: 'Muster AG',
senderAddress: 'Bahnhofstrasse 1\n8000 Zürich',
senderEmail: 'hello@muster.ch',
senderVatNumber: null,
senderIban: 'CH9300762011623852957',
senderBic: null,
logoMediaId: null,
accentColor: '#059669',
footer: null,
numberPrefix: '2026-',
numberPadding: 4,
nextNumber: 1,
defaultCurrency: 'CHF',
defaultVatRate: 8.1,
defaultDueDays: 30,
defaultTerms: null,
...overrides,
};
}
function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: 'i1',
number: '2026-0042',
status: 'draft',
clientId: null,
clientSource: 'invoice-client',
clientSnapshot: {
name: 'Kunde GmbH',
address: 'Seefeldstrasse 5\n8008 Zürich',
email: 'kunde@example.ch',
},
currency: 'CHF',
issueDate: '2026-04-20',
dueDate: '2026-05-20',
sentAt: null,
paidAt: null,
lines: [],
subject: 'Beratung',
notes: null,
terms: null,
referenceNumber: null,
pdfBlobKey: null,
totals: {
net: 10000,
vat: 810,
gross: 10810,
vatBreakdown: [{ rate: 8.1, base: 10000, tax: 810 }],
},
createdAt: '2026-04-20T00:00:00.000Z',
updatedAt: '2026-04-20T00:00:00.000Z',
...overrides,
};
}
// ─── SCOR reference ─────────────────────────────────────
describe('generateSCORReference', () => {
it('produces a spec-valid SCOR reference', () => {
const ref = generateSCORReference('2026-0042');
expect(isSCORReferenceValid(ref)).toBe(true);
});
it('starts with RF', () => {
expect(generateSCORReference('2026-0042')).toMatch(/^RF/);
});
it('is deterministic for the same invoice number', () => {
expect(generateSCORReference('2026-0042')).toBe(generateSCORReference('2026-0042'));
});
it('strips non-alphanumerics from the payload', () => {
const ref = generateSCORReference('2026-0042');
expect(ref).not.toContain('-');
});
it('truncates payloads to 21 chars so the overall reference fits spec (25)', () => {
const ref = generateSCORReference('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
// RF + 2 check digits + ≤21 payload = 25 chars total
expect(ref.length).toBeLessThanOrEqual(25);
});
it('falls back for all-separator input', () => {
const ref = generateSCORReference('---');
expect(isSCORReferenceValid(ref)).toBe(true);
});
});
// ─── buildQRBillData ─────────────────────────────────────
describe('buildQRBillData', () => {
it('builds a valid data object for CHF invoice with all fields set', () => {
const data = buildQRBillData(makeInvoice(), makeSettings());
expect(data.currency).toBe('CHF');
expect(data.creditor.account).toBe('CH9300762011623852957');
expect(data.creditor.zip).toBe('8000');
expect(data.creditor.city).toBe('Zürich');
expect(data.debtor?.zip).toBe('8008');
expect(data.amount).toBeCloseTo(108.1, 2);
expect(data.reference).toBeTruthy();
});
it('rejects USD (only CHF + EUR allowed)', () => {
const invoice = makeInvoice({ currency: 'USD' });
expect(() => buildQRBillData(invoice, makeSettings())).toThrow(QRBillError);
try {
buildQRBillData(invoice, makeSettings());
} catch (e) {
expect(e).toBeInstanceOf(QRBillError);
expect((e as QRBillError).reason).toBe('invalid-currency');
}
});
it('rejects missing IBAN with a specific reason', () => {
try {
buildQRBillData(makeInvoice(), makeSettings({ senderIban: '' }));
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(QRBillError);
expect((e as QRBillError).reason).toBe('missing-iban');
}
});
it('rejects invalid IBAN (bad checksum)', () => {
try {
buildQRBillData(makeInvoice(), makeSettings({ senderIban: 'CH0000000000000000000' }));
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(QRBillError);
expect((e as QRBillError).reason).toBe('invalid-iban');
}
});
it('rejects unparseable sender address', () => {
try {
buildQRBillData(makeInvoice(), makeSettings({ senderAddress: 'eine Zeile nur' }));
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(QRBillError);
expect((e as QRBillError).reason).toBe('unparseable-sender-address');
}
});
it('rejects zero / negative amount', () => {
const invoice = makeInvoice({
totals: { net: 0, vat: 0, gross: 0, vatBreakdown: [] },
});
try {
buildQRBillData(invoice, makeSettings());
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(QRBillError);
expect((e as QRBillError).reason).toBe('missing-amount');
}
});
it('accepts unparseable client address and omits the debtor block', () => {
// Debtor is optional per spec — we still emit the QR-Bill, the user
// can fill the payer side by hand when paying.
const invoice = makeInvoice({
clientSnapshot: { name: 'Bar', address: 'nur eine zeile' },
});
const data = buildQRBillData(invoice, makeSettings());
expect(data.debtor).toBeUndefined();
});
it('parses multi-line street with house number on line 1', () => {
const data = buildQRBillData(
makeInvoice(),
makeSettings({ senderAddress: 'Bahnhofstrasse 1\nPostfach\n8000 Zürich' })
);
// Lines 1+2 join as street; line 3 is the zip+city.
expect(data.creditor.address).toBe('Bahnhofstrasse 1, Postfach');
expect(data.creditor.zip).toBe('8000');
});
it('parses 5-digit German postcodes', () => {
const data = buildQRBillData(
makeInvoice({ currency: 'EUR' }),
makeSettings({
senderIban: 'DE89370400440532013000',
senderAddress: 'Hauptstraße 5\n10115 Berlin',
})
);
expect(data.creditor.zip).toBe('10115');
expect(data.creditor.city).toBe('Berlin');
});
it('uses referenceNumber from invoice when set (preferred over regen)', () => {
const invoice = makeInvoice({ referenceNumber: 'RF18539007547034' });
const data = buildQRBillData(invoice, makeSettings());
expect(data.reference).toBe('RF18539007547034');
});
it('regenerates reference when invoice.referenceNumber is null (migration path)', () => {
const invoice = makeInvoice({ referenceNumber: null });
const data = buildQRBillData(invoice, makeSettings());
expect(data.reference).toBeTruthy();
expect(data.reference).toMatch(/^RF/);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals';
import type { LocalInvoiceLine } from './types';
function line(overrides: Partial<LocalInvoiceLine> = {}): LocalInvoiceLine {
return {
id: 'l',
title: 't',
description: null,
quantity: 1,
unit: null,
unitPrice: 10000,
vatRate: 8.1,
discount: null,
...overrides,
};
}
describe('computeLineTotal', () => {
it('returns net+tax in minor units with no discount', () => {
const { net, tax } = computeLineTotal(line({ quantity: 4, unitPrice: 15000, vatRate: 8.1 }));
expect(net).toBe(60000);
// 60000 * 8.1 / 100 = 4860
expect(tax).toBe(4860);
});
it('applies percentage discount before VAT', () => {
// 10.- at 10% discount = 9.- net; 19% VAT = 1.71
const { net, tax } = computeLineTotal(
line({ quantity: 1, unitPrice: 1000, vatRate: 19, discount: 10 })
);
expect(net).toBe(900);
expect(tax).toBe(171);
});
it('rounds tax half-up at the per-line level', () => {
// 123 @ 8.1% = 9.963 → rounds to 10
const { tax } = computeLineTotal(line({ quantity: 1, unitPrice: 123, vatRate: 8.1 }));
expect(tax).toBe(10);
});
it('handles zero VAT rate', () => {
const { net, tax } = computeLineTotal(line({ quantity: 2, unitPrice: 5000, vatRate: 0 }));
expect(net).toBe(10000);
expect(tax).toBe(0);
});
});
describe('computeInvoiceTotals', () => {
it('returns EMPTY_TOTALS-equivalent for no lines', () => {
const totals = computeInvoiceTotals([]);
expect(totals.net).toBe(0);
expect(totals.vat).toBe(0);
expect(totals.gross).toBe(0);
expect(totals.vatBreakdown).toEqual([]);
});
it('sums a single-rate invoice', () => {
const totals = computeInvoiceTotals([
line({ quantity: 2, unitPrice: 10000, vatRate: 8.1 }),
line({ id: 'l2', quantity: 1, unitPrice: 5000, vatRate: 8.1 }),
]);
expect(totals.net).toBe(25000); // 20000 + 5000
expect(totals.vat).toBe(1620 + 405); // 2025
expect(totals.gross).toBe(totals.net + totals.vat);
expect(totals.vatBreakdown).toHaveLength(1);
expect(totals.vatBreakdown[0]).toEqual({ rate: 8.1, base: 25000, tax: 2025 });
});
it('groups by VAT rate and sorts breakdown ascending', () => {
const totals = computeInvoiceTotals([
line({ quantity: 1, unitPrice: 10000, vatRate: 8.1 }),
line({ id: 'l2', quantity: 1, unitPrice: 5000, vatRate: 2.6 }),
line({ id: 'l3', quantity: 1, unitPrice: 2000, vatRate: 8.1 }),
]);
expect(totals.vatBreakdown.map((b) => b.rate)).toEqual([2.6, 8.1]);
const [low, high] = totals.vatBreakdown;
expect(low.base).toBe(5000);
expect(high.base).toBe(12000);
});
it('preserves the invariant: breakdown sums equal invoice totals', () => {
// Critical for the PDF footer + dashboard — if this drifts, users
// see "sum of VAT rows" ≠ "VAT total" and lose trust.
const lines = [
line({ quantity: 3.5, unitPrice: 7777, vatRate: 8.1 }),
line({ id: 'l2', quantity: 1, unitPrice: 3333, vatRate: 2.6, discount: 15 }),
line({ id: 'l3', quantity: 2, unitPrice: 999, vatRate: 0 }),
];
const totals = computeInvoiceTotals(lines);
const breakdownBase = totals.vatBreakdown.reduce((s, b) => s + b.base, 0);
const breakdownTax = totals.vatBreakdown.reduce((s, b) => s + b.tax, 0);
expect(breakdownBase).toBe(totals.net);
expect(breakdownTax).toBe(totals.vat);
});
it('EMPTY_TOTALS matches an empty computation', () => {
expect(computeInvoiceTotals([])).toEqual(EMPTY_TOTALS);
});
});

View file

@ -0,0 +1,146 @@
<script lang="ts">
/**
* InvoicesOpenWidget — offene + überfällige Summen + die 3 ältesten
* überfälligen Rechnungen, direkt aus Dexie gelesen.
*
* Zeigt pro Primär-Währung (zuerst CHF → EUR → USD) die offene +
* überfällige Summe. Mehrwährungs-Setups sehen nur die Leitwährung;
* detaillierte Aufschlüsselung gibt's im ListView.
*/
import { liveQuery } from 'dexie';
import { invoiceTable } from '$lib/modules/invoices/collections';
import { decryptRecords } from '$lib/data/crypto';
import { toInvoice, computeStats, formatAmount } from '$lib/modules/invoices/queries';
import type { Invoice, InvoiceStatus, Currency } from '$lib/modules/invoices/types';
let invoices = $state<Invoice[]>([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const rows = await invoiceTable.toArray();
const visible = rows.filter((r) => !r.deletedAt);
const decrypted = await decryptRecords('invoices', visible);
const today = new Date().toISOString().slice(0, 10);
return decrypted.map((local) => {
const inv = toInvoice(local);
if (inv.status === 'sent' && inv.dueDate < today) {
return { ...inv, status: 'overdue' as InvoiceStatus };
}
return inv;
});
}).subscribe({
next: (result) => {
invoices = result;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const currentYear = new Date().getFullYear();
const stats = $derived(computeStats(invoices, currentYear));
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 overdueCount = $derived(stats.totalByStatus.overdue);
const openSum = $derived(stats.openByCurrency[openCurrency]);
const overdueSum = $derived(stats.overdueByCurrency[openCurrency]);
// Top 3 oldest overdue — the ones the user should chase first.
const topOverdue = $derived(
invoices
.filter((i) => i.status === 'overdue')
.sort((a, b) => a.dueDate.localeCompare(b.dueDate))
.slice(0, 3)
);
function daysSince(dueDate: string): number {
const due = new Date(`${dueDate}T00:00:00`);
const now = new Date();
return Math.floor((now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24));
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span aria-hidden="true">📄</span>
Rechnungen
</h3>
<a href="/invoices" class="text-xs text-muted-foreground hover:text-foreground"> Alle → </a>
</div>
{#if loading}
<div class="space-y-2">
{#each Array(3) as _}
<div class="h-10 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if invoices.length === 0}
<div class="py-4 text-center">
<p class="text-sm text-muted-foreground">Noch keine Rechnungen gestellt.</p>
<a
href="/invoices/new"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Erste Rechnung
</a>
</div>
{:else}
<div class="mb-3 grid grid-cols-2 gap-2">
<div class="rounded-lg bg-surface-hover p-2.5">
<div class="text-xs text-muted-foreground">Offen</div>
<div class="text-lg font-semibold tabular-nums">
{formatAmount(openSum, openCurrency)}
</div>
</div>
<div
class="rounded-lg p-2.5"
class:bg-surface-hover={overdueSum === 0}
class:bg-red-50={overdueSum > 0}
>
<div class="text-xs text-muted-foreground">Überfällig</div>
<div class="text-lg font-semibold tabular-nums" class:text-red-700={overdueSum > 0}>
{formatAmount(overdueSum, openCurrency)}
</div>
{#if overdueCount > 0}
<div class="text-[10px] text-red-700">{overdueCount} Rechnungen</div>
{/if}
</div>
</div>
{#if topOverdue.length > 0}
<div class="space-y-1.5">
{#each topOverdue as invoice (invoice.id)}
<a
href="/invoices/{invoice.id}"
class="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">
{invoice.clientSnapshot.name}
</div>
<div class="text-xs text-red-700">
{daysSince(invoice.dueDate)} Tage überfällig
</div>
</div>
<div class="ml-2 text-sm font-medium tabular-nums">
{formatAmount(invoice.totals.gross, invoice.currency)}
</div>
</a>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -56,6 +56,7 @@ describe('WIDGET_REGISTRY', () => {
'food',
'plants',
'period',
'body',
undefined,
];
for (const widget of WIDGET_REGISTRY) {

View file

@ -32,7 +32,8 @@ export type WidgetType =
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'period' // Period: current phase + days until next period
| 'news-unread' // News: latest unread curated articles
| 'body-stats'; // Body: latest weight + active workout summary
| 'body-stats' // Body: latest weight + active workout summary
| 'invoices-open'; // Invoices: open/overdue totals + oldest overdue
/**
* Widget size - maps to CSS Grid columns
@ -362,6 +363,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'body',
},
{
type: 'invoices-open',
nameKey: 'dashboard.widgets.invoices_open.title',
descriptionKey: 'dashboard.widgets.invoices_open.description',
icon: '📄',
defaultSize: 'medium',
allowMultiple: false,
},
];
/**

View file

@ -2,9 +2,29 @@
## Status (2026-04-20)
**M0 Planning** — dieser Plan. Noch kein Code.
**M1M7 DONE** — solo-tauglich, ohne Logo-Upload + AI-Tools.
Nächster Schritt: M1 Skelett (Modul registriert, Dexie-Table, Guest-Seed, leere ListView).
| Milestone | Commit | Notes |
|---|---|---|
| M1 Skelett | `2cf89ce26` | Modul registriert, Dexie v27, Encryption-Registry, leere ListView |
| M2 CRUD | `8d00ee069` | Store, Totals, Status-Maschine, ListView mit Stats-Cards, Routes |
| M3 Settings | in M2 | SenderProfileForm fertig, **Logo-Upload offen** (uload-Integration nötig) |
| M4 PDF Basic | `2dc298a79` | pdf-lib, pagination, preview + download |
| M5 QR-Rechnung | `5af23d30b` | swissqrbill/svg + PNG-Overlay, SCOR auto, address-parser-Heuristik |
| M6 Versand | `08b7ac16b` | SendModal mit mailto + PDF-Download + Confirm → status sent |
| M7 Dashboard | pending commit | InvoicesOpenWidget mit Offen/Überfällig + Top-3 älteste überfällig |
**Tests (45+ green):** totals math, SCOR + address-parser (swissqrbill `isSCORReferenceValid` roundtrip), mail template + mailto encoding.
**Offene Arbeit vor "produktiv einsetzbar":**
- **M3-Polish:** Logo-Upload via uload → erscheint im PDF-Header
- **M8 AI-Tools:** `create_invoice` (propose), `mark_paid` (propose), `list_invoices` (auto), `get_invoice_stats` (auto) — braucht Planner-Drift-Guard-Update
- **Nummernkreis-Multi-Device-Kollision:** dokumentiert als gap (pragmatisch, solo-MVP-tauglich)
- **Strukturierte Adressen:** Heuristik-Parser deckt Standard-CH/DE ab; komplexere Adressen fehlen → User sieht in Warning, was fehlt
- **Bezahlte Rechnung → finance-Transaktion:** Cross-Modul-Hook, Phase 3
- **camt.053 Bankabgleich:** Phase 2
Nächster Schritt: M3 Logo-Upload ODER M8 AI-Tools ODER Real-World-Dogfooding (erste eigene Rechnung an einen echten Kunden).
---