diff --git a/CHANGELOG.md b/CHANGELOG.md index fd302a8..e75d180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ Versionen folgen [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +## [0.16.0] + +> **Internationalisierung + Detail-Schliff.** Englische PDF-Variante, mehrsprachige Katalog-Beschreibungen, kuratierte Ausgaben-Kategorien mit SKR03/SKR04-Konten-Mapping, und Bulk-Aktionen jetzt auch für Angebote und Eingangsrechnungen. + +### Added +- **Englische PDF-Variante** (Migration `0024_v0.16_pdf_language.sql`). Pro Rechnung und Angebot wählbar zwischen Deutsch und Englisch, Default in Settings → Dokumente. Die Übersetzung deckt alle Labels (Tabellenköpfe, Meta-Box, Totals, Rechtshinweise, Footer-Spalten) ab — das eingebettete ZUGFeRD-XML bleibt bewusst sprach-neutral. Neue Sidecar-Module `invoice/i18n.py` mit Translation-Dict für DE und EN, Templates nutzen jetzt `{{ t('key', lang) }}` durchgängig. +- **Mehrsprachige Katalog-Beschreibungen** sind jetzt im Picker aktiv. Wenn ein Katalog-Eintrag eine `description_en` hat und die Rechnung auf Englisch gestellt ist, wird die englische Beschreibung in den Item-Block eingefügt — sonst Fallback auf Deutsch. Schema (`catalog_items.description_en`) existierte bereits seit v0.14; jetzt wird sie auch genutzt. +- **Kuratierte Ausgaben-Kategorien** (Migration `0025_v0.16_expense_categories.sql`). Neue Tabelle `expense_categories` mit 10 seed-Einträgen (Software/SaaS, Hardware, Telefon, Reisekosten, Fortbildung, Bürobedarf, Honorare, Werbung, Bankgebühren, Sonstige), jeweils mit SKR03- **und** SKR04-Konto-Mapping. ExpenseEdit-Item-Block bekommt einen Kategorie-Select; bei Auswahl wird das `datev_account`-Feld automatisch mit dem SKR03-Default vorbelegt. Beim DATEV-Export wird das Konto über die `categoryId` zur Laufzeit gegen das jeweils aktive SKR-Profil aufgelöst — ein Eintrag mit `categoryId` wechselt also je nach Export-Auswahl korrekt zwischen SKR03 und SKR04. Legacy-Freitext-Kategorie bleibt als Fallback erhalten und ist editierbar wenn keine kuratierte Kategorie gewählt ist. +- **Bulk-Aktionen in OffersList.** Checkbox-Spalte + Select-All. Bar zeigt kontextsensitiv: „Versenden" (alle Drafts), „Ablehnen" (alle Drafts/Sent), „Löschen" (alle Drafts/Rejected/Expired). Angenommene Angebote bleiben vor Bulk-Delete geschützt. +- **Bulk-Aktionen in ExpensesList.** Selbes Muster: „Als bezahlt" (alle Open) und „Löschen" (alle Drafts/Cancelled). Offene und bezahlte Belege werden vor Bulk-Delete geschützt. + +### Changed +- **`Settings.defaultPdfLanguage`** als neue Spalte mit `'de'`-Default. InvoiceEdit und OfferEdit füllen den Sprach-Selector aus dieser Vorgabe vor. +- **DATEV-Export** löst `categoryId` zur Export-Zeit gegen das gewählte SKR-Profil auf. Explizit gesetzte `datev_account`-Overrides auf Item-Ebene gewinnen weiterhin gegen die Kategorie-Auflösung — User-Intent ist King. + +### Migration +- `0024_v0.16_pdf_language.sql` — `user_version = 25`. `pdf_language`-Spalten in `invoices`, `offers`, `settings`. +- `0025_v0.16_expense_categories.sql` — `user_version = 26`. `expense_categories`-Tabelle mit 10 builtin-Einträgen + `expense_items.category_id`. +- `CURRENT_SCHEMA` in `backup.rs` auf **26**, `CURRENT_DB_SCHEMA_VERSION` in `Advanced.svelte` / `Data.svelte` und in `lib/utils/auto-backup.ts` synchron. + +### Notes +- **Page-Counter im PDF-Footer** (`Seite N von M`) ist über CSS-Counter realisiert und bleibt in v0.16 sprach-fest auf Deutsch. Für englische Rechnungen also ein kleiner sichtbarer DE-String am Seitenrand. Korrektur erfordert WeasyPrint-Magic mit `string-set`/`@page`-Pseudo-Selektoren — kommt bei Bedarf in einer späteren Version. +- **Custom Ausgaben-Kategorien** (über die 10 builtin-Einträge hinaus) sind heute nur direkt in der DB anlegbar — eine Settings-Verwaltungs-UI folgt in v0.17. Die DB-Schicht (`createCategory`/`updateCategory`/`setCategoryArchived`) ist bereits vorhanden. +- **Legacy-Freitext-Kategorien** in bestehenden Eingangsrechnungen bleiben unangetastet — `expense_items.category` (freier Text) wird weiterhin als Fallback gerendert. Beim Re-Save ohne Kategorie-Pick bleibt der Freitext erhalten. + ## [0.15.0] > **Zahlungs-Workflow.** Vier zusammenhängende Schritte schließen die Lücke zwischen „Rechnung versendet" und „Geld auf dem Konto": **Teilzahlungen** mit eigener Tabelle, **Bank-Import** für CAMT.053/MT940 mit Auto-Match auf Rechnungsnummer, **Bulk-Aktionen** in der Rechnungsliste und **rotierendes Auto-Backup** beim App-Start. diff --git a/package.json b/package.json index 192adb6..5be9181 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zettel", - "version": "0.15.0", + "version": "0.16.0", "description": "Offline-first invoice generator with ZUGFeRD/Factur-X support", "type": "module", "scripts": { diff --git a/sidecar/invoice/i18n.py b/sidecar/invoice/i18n.py new file mode 100644 index 0000000..7b657a3 --- /dev/null +++ b/sidecar/invoice/i18n.py @@ -0,0 +1,163 @@ +"""Translation-Dict für die PDF-Templates. + +Ergänzungs-Workflow für neue Strings: + 1. Schlüssel hier in beide Sprachen eintragen. + 2. Im Template `{{ t('schluessel') }}` verwenden. + 3. Tests in test_i18n_pdf.py erweitern. + +Wir halten die Schlüssel klein, semantisch und ohne Punkt-Notation — +für ein Solo-Tool reicht ein flaches Mapping. Bei Bedarf später auf +namespaces splitten. + +Datums-/Geld-Formatierung bleibt im Filter (`date_unix`, `eur`); die +i18n-Schicht ist nur für reine Labels. +""" +from __future__ import annotations + + +_DE = { + "doctype_invoice": "Rechnung", + "doctype_credit_note": "Stornorechnung", + "doctype_offer": "Angebot", + + # Meta-Box + "meta_invoice_no": "Rechnungsnr.", + "meta_offer_no": "Angebotsnr.", + "meta_issue_date": "Rechnungsdatum", + "meta_offer_date": "Angebotsdatum", + "meta_service_period": "Leistungszeitraum", + "meta_delivery_date": "Leistungsdatum", + "meta_due_date": "Fällig am", + "meta_valid_until": "Gültig bis", + "meta_customer_vatid": "USt-IdNr. Kunde", + + # Items table + "col_pos": "Pos.", + "col_desc": "Beschreibung", + "col_qty": "Menge", + "col_unit": "Einheit", + "col_price": "Einzelpreis", + "col_vat": "USt %", + "col_total": "Gesamt", + "item_period_single": "Leistungsdatum", + "item_period_range": "Leistungszeitraum", + + # Totals + "totals_subtotal": "Zwischensumme", + "totals_vat": "Umsatzsteuer", + "totals_grand": "Gesamtbetrag", + "totals_already_paid": "Bereits gezahlt", + "totals_remaining": "Noch offen", + + # Blocks + "block_refund_title": "Erstattung", + "block_refund_body": "Der Betrag wird auf Ihr Konto erstattet.", + "block_payment_terms": "Zahlungsbedingungen", + "block_notes": "Hinweise", + "block_skonto_offer": "Skonto-Angebot", + + "rc_intra_eu": "Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be paid by the recipient.", + "rc_third_country": "Steuerfreie Ausfuhrlieferung / Export outside EU — VAT exempt.", + + "skonto_inline": "Bei Zahlung bis {date}: {percent} % Skonto = {amount}", + "skonto_offer_text": "Bei Zahlung der späteren Rechnung innerhalb von {days} Tagen ab Rechnungsdatum gewähren wir {percent} % Skonto.", + + "credit_note_ref": "Storno zur Rechnung {number} vom {date}.", + "offer_disclaimer": "Dieses Angebot ist freibleibend und unverbindlich. Gültig bis {date}. Bei Annahme erstellen wir eine entsprechende Rechnung.", + "offer_rc_hint": "Bei Auftragsannahme wird die Rechnung mit dem Hinweis \"Steuerschuldnerschaft des Leistungsempfängers\" ausgestellt (Reverse Charge).", + + # Footer + "footer_contact": "Kontakt", + "footer_tax": "Steuer", + "footer_bank": "Bankverbindung", + "footer_tax_no": "St-Nr.", + "footer_vat_id": "USt-IdNr.", + + # EPC-QR + "epc_qr_caption_line1": "QR-Code scannen", + "epc_qr_caption_line2": "für sofortige Überweisung", + + # Address + "addr_attention": "z. Hd.", + "page_n_of_m": "Seite {n} von {m}", +} + + +_EN = { + "doctype_invoice": "Invoice", + "doctype_credit_note": "Credit Note", + "doctype_offer": "Quotation", + + "meta_invoice_no": "Invoice no.", + "meta_offer_no": "Quotation no.", + "meta_issue_date": "Invoice date", + "meta_offer_date": "Quotation date", + "meta_service_period": "Service period", + "meta_delivery_date": "Delivery date", + "meta_due_date": "Due date", + "meta_valid_until": "Valid until", + "meta_customer_vatid": "Buyer VAT ID", + + "col_pos": "No.", + "col_desc": "Description", + "col_qty": "Qty", + "col_unit": "Unit", + "col_price": "Unit price", + "col_vat": "VAT %", + "col_total": "Total", + "item_period_single": "Service date", + "item_period_range": "Service period", + + "totals_subtotal": "Subtotal", + "totals_vat": "VAT", + "totals_grand": "Total", + "totals_already_paid": "Already paid", + "totals_remaining": "Remaining", + + "block_refund_title": "Refund", + "block_refund_body": "The amount will be refunded to your account.", + "block_payment_terms": "Payment terms", + "block_notes": "Notes", + "block_skonto_offer": "Early-payment discount", + + "rc_intra_eu": "Reverse charge — VAT to be paid by the recipient (intra-EU supply).", + "rc_third_country": "Export outside EU — VAT exempt.", + + "skonto_inline": "Pay by {date}: {percent}% discount = {amount}", + "skonto_offer_text": "If the subsequent invoice is paid within {days} days of invoice date, we grant a {percent}% early-payment discount.", + + "credit_note_ref": "Credit note for invoice {number} dated {date}.", + "offer_disclaimer": "This quotation is non-binding. Valid until {date}. Upon acceptance we will issue a corresponding invoice.", + "offer_rc_hint": "Upon acceptance, the invoice will be issued with the note \"VAT to be paid by the recipient (reverse charge)\".", + + "footer_contact": "Contact", + "footer_tax": "Tax", + "footer_bank": "Bank details", + "footer_tax_no": "Tax no.", + "footer_vat_id": "VAT ID", + + "epc_qr_caption_line1": "Scan QR code", + "epc_qr_caption_line2": "for instant SEPA transfer", + + "addr_attention": "Attn.", + "page_n_of_m": "Page {n} of {m}", +} + + +_TABLES = {"de": _DE, "en": _EN} + + +def t(key: str, language: str = "de", **kwargs) -> str: + """Look up a translation key. Falls back to DE if the lang/key is unknown. + + Keyword args are passed through to `str.format` for interpolation — + keeps the template side simple (`{{ t('skonto_inline', date=..., percent=...) }}`). + """ + table = _TABLES.get(language) or _DE + raw = table.get(key) or _DE.get(key) or key + if kwargs: + try: + return raw.format(**kwargs) + except (KeyError, IndexError): + return raw + return raw diff --git a/sidecar/invoice/templates.py b/sidecar/invoice/templates.py index 74de8f1..e31b51a 100644 --- a/sidecar/invoice/templates.py +++ b/sidecar/invoice/templates.py @@ -7,6 +7,8 @@ from jinja2 import Environment, FileSystemLoader +from .i18n import t as translate + def _templates_dir() -> Path: # When frozen by PyInstaller, templates ship next to the binary in _MEIPASS. @@ -54,4 +56,7 @@ def build_env() -> Environment: env.filters["eur"] = cents_to_eur env.filters["qty"] = fmt_qty env.filters["date_unix"] = fmt_date_unix + # `t()` ist als globale Funktion verfügbar; Templates rufen + # {{ t('schluessel', language) }} oder mit kwargs für Interpolation. + env.globals["t"] = translate return env diff --git a/sidecar/templates/invoice.html.j2 b/sidecar/templates/invoice.html.j2 index 2f87250..e7f5048 100644 --- a/sidecar/templates/invoice.html.j2 +++ b/sidecar/templates/invoice.html.j2 @@ -1,8 +1,9 @@ - +{% set lang = invoice.pdfLanguage or 'de' %} +
-