From 25c948e930d723cbbc2207b67b7a34dd11cf56a8 Mon Sep 17 00:00:00 2001 From: jonax1337 Date: Fri, 22 May 2026 09:53:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(v0.16):=20Englische=20PDF=20=C2=B7=20Katal?= =?UTF-8?q?og-EN=20=C2=B7=20Ausgaben-Kategorien=20=C2=B7=20Bulk-Aktionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internationalisierung und Detail-Schliff: - Englische PDF-Variante (Migration 0024). pdf_language pro Rechnung + Angebot, Settings-Default. Sidecar i18n.py mit DE/EN-Translation-Dict; Templates nutzen {{ t('key', lang) }} durchgängig. ZUGFeRD-XML bleibt sprach-neutral. - Mehrsprachige Katalog-Beschreibungen aktiviert. CatalogPicker zeigt description_en bei EN-Rechnungen mit Fallback auf description_de. - Kuratierte Ausgaben-Kategorien (Migration 0025). 10 builtin-Einträge mit SKR03+SKR04-Konten-Mapping. ExpenseEdit-Kategorie-Select mit Auto-Fill für datev_account. DATEV-Export löst categoryId zur Export-Zeit gegen aktives SKR-Profil auf. - Bulk-Aktionen in OffersList (Versenden / Ablehnen / Löschen) und ExpensesList (Als bezahlt / Löschen). Schutz für bereits-finalisierte Belege (accepted Offers, open/paid Expenses) vor Bulk-Delete. DB-Migrations: user_version 24 → 26. CURRENT_SCHEMA an drei Stellen synchronisiert. Tests: 87/87 Vitest, 34/34 Pytest (+18 KoSIT-skipped), Cargo clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 25 +++ package.json | 2 +- sidecar/invoice/i18n.py | 163 ++++++++++++++++++ sidecar/invoice/templates.py | 5 + sidecar/templates/invoice.html.j2 | 88 +++++----- sidecar/templates/offer.html.j2 | 66 ++++--- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/backup.rs | 2 +- src-tauri/src/lib.rs | 12 ++ src-tauri/tauri.conf.json | 2 +- src/lib/db/expense-categories.ts | 95 ++++++++++ src/lib/db/expenses.ts | 8 +- src/lib/db/invoices.ts | 10 +- .../db/migrations/0024_v0.16_pdf_language.sql | 12 ++ .../0025_v0.16_expense_categories.sql | 41 +++++ src/lib/db/offers.ts | 11 +- src/lib/db/queries.ts | 4 + src/lib/db/reminders.ts | 1 + src/lib/db/schema.ts | 20 +++ src/lib/sidecar/invoice.ts | 1 + src/lib/sidecar/offer.ts | 1 + src/lib/ui/CatalogPicker.svelte | 12 +- src/lib/utils/auto-backup.ts | 2 +- src/routes/ExpenseEdit.svelte | 64 ++++++- src/routes/ExpensesList.svelte | 82 ++++++++- src/routes/Export.svelte | 25 ++- src/routes/InvoiceEdit.svelte | 29 +++- src/routes/OfferEdit.svelte | 26 ++- src/routes/OffersList.svelte | 92 +++++++++- src/routes/settings/Advanced.svelte | 2 +- src/routes/settings/Data.svelte | 2 +- src/routes/settings/Documents.svelte | 18 ++ 33 files changed, 811 insertions(+), 116 deletions(-) create mode 100644 sidecar/invoice/i18n.py create mode 100644 src/lib/db/expense-categories.ts create mode 100644 src/lib/db/migrations/0024_v0.16_pdf_language.sql create mode 100644 src/lib/db/migrations/0025_v0.16_expense_categories.sql 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' %} + - {% if invoice.isCreditNote %}Stornorechnung{% else %}Rechnung{% endif %} {{ invoice.number }} + {{ t('doctype_credit_note' if invoice.isCreditNote else 'doctype_invoice', lang) }} {{ invoice.number }} @@ -13,14 +14,14 @@ {% if company.ownerName %}
{{ company.ownerName }}
{% endif %}
-
{% if invoice.isCreditNote %}Stornorechnung{% else %}Rechnung{% endif %}
+
{{ t('doctype_credit_note' if invoice.isCreditNote else 'doctype_invoice', lang) }}
{{ invoice.number }}
{% if invoice.isCreditNote and invoice.correctsInvoice %}
- Storno zur Rechnung {{ invoice.correctsInvoice.number }} vom {{ invoice.correctsInvoice.issueDate | date_unix }}. + {{ t('credit_note_ref', lang, number=invoice.correctsInvoice.number, date=invoice.correctsInvoice.issueDate | date_unix) }}
{% endif %} @@ -32,7 +33,7 @@ {%- if company.postalCode %} · {{ company.postalCode }} {{ company.city }}{% endif %}
{{ customer.name }}
- {% if customer.contactPerson %}
z. Hd. {{ customer.contactPerson }}
{% endif %} + {% if customer.contactPerson %}
{{ t('addr_attention', lang) }} {{ customer.contactPerson }}
{% endif %}
{{ customer.street }}
{{ customer.postalCode }} {{ customer.city }}
{% if customer.country and customer.country != "DE" %}
{{ customer.country }}
{% endif %} @@ -40,33 +41,33 @@
- Rechnungsnr. + {{ t('meta_invoice_no', lang) }} {{ invoice.number }}
- Rechnungsdatum + {{ t('meta_issue_date', lang) }} {{ invoice.issueDate | date_unix }}
{% if invoice.servicePeriodStart and invoice.servicePeriodEnd %}
- Leistungszeitraum + {{ t('meta_service_period', lang) }} {{ invoice.servicePeriodStart | date_unix }} – {{ invoice.servicePeriodEnd | date_unix }}
{% elif invoice.deliveryDate %}
- Leistungsdatum + {{ t('meta_delivery_date', lang) }} {{ invoice.deliveryDate | date_unix }}
{% endif %} {% if not invoice.isCreditNote %}
- Fällig am + {{ t('meta_due_date', lang) }} {{ invoice.dueDate | date_unix }}
{% endif %} {% if customer.vatId %}
- USt-IdNr. Kunde + {{ t('meta_customer_vatid', lang) }} {{ customer.vatId }}
{% endif %} @@ -76,13 +77,13 @@ - - - - - - {% if not invoice.isKleinunternehmer and not invoice.isReverseCharge %}{% endif %} - + + + + + + {% if not invoice.isKleinunternehmer and not invoice.isReverseCharge %}{% endif %} + @@ -96,9 +97,9 @@ {% endif %} {% if it.linePeriodStart and it.linePeriodEnd %} {% if it.linePeriodStart == it.linePeriodEnd %} -
Leistungsdatum {{ it.linePeriodStart | date_unix }}
+
{{ t('item_period_single', lang) }} {{ it.linePeriodStart | date_unix }}
{% else %} -
Leistungszeitraum {{ it.linePeriodStart | date_unix }} – {{ it.linePeriodEnd | date_unix }}
+
{{ t('item_period_range', lang) }} {{ it.linePeriodStart | date_unix }} – {{ it.linePeriodEnd | date_unix }}
{% endif %} {% endif %} @@ -115,26 +116,26 @@
Pos.BeschreibungMengeEinheitEinzelpreisUSt %Gesamt{{ t('col_pos', lang) }}{{ t('col_desc', lang) }}{{ t('col_qty', lang) }}{{ t('col_unit', lang) }}{{ t('col_price', lang) }}{{ t('col_vat', lang) }}{{ t('col_total', lang) }}
- + {% if not invoice.isKleinunternehmer and not invoice.isReverseCharge %} - + {% endif %} - + {% if invoice.amountPaidCent and invoice.amountPaidCent > 0 %} - + - + {% endif %} @@ -146,39 +147,36 @@ {% endif %} {% if invoice.reverseChargeType == 'intra_eu' %} -

- Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be paid by the recipient. -

+

{{ t('rc_intra_eu', lang) }}

{% elif invoice.reverseChargeType == 'third_country' %} -

- Steuerfreie Ausfuhrlieferung / Export outside EU — VAT exempt. -

+

{{ t('rc_third_country', lang) }}

{% endif %} {% if invoice.isCreditNote %}
-
Erstattung
-

Der Betrag wird auf Ihr Konto erstattet.

+
{{ t('block_refund_title', lang) }}
+

{{ t('block_refund_body', lang) }}

{% elif invoice.paymentTerms or invoice.skontoPercent or epc_qr_uri %}
{% if invoice.paymentTerms %} -
Zahlungsbedingungen
+
{{ t('block_payment_terms', lang) }}

{{ invoice.paymentTerms }}

{% endif %} {% if invoice.skontoPercent and invoice.skontoDays and invoice.skontoAmountCent %}

- Bei Zahlung bis {{ invoice.skontoDeadline | date_unix }}: - {{ "%.1f"|format(invoice.skontoPercent) }} % Skonto = - {{ invoice.skontoAmountCent | eur(invoice.currency) }} + {{ t('skonto_inline', lang, + date=invoice.skontoDeadline | date_unix, + percent='%.1f'|format(invoice.skontoPercent), + amount=invoice.skontoAmountCent | eur(invoice.currency)) }}

{% endif %}
{% if epc_qr_uri %}
- EPC-QR-Code für SEPA-Überweisung -
QR-Code scannen
für sofortige Überweisung
+ EPC-QR-Code +
{{ t('epc_qr_caption_line1', lang) }}
{{ t('epc_qr_caption_line2', lang) }}
{% endif %}
@@ -186,7 +184,7 @@ {% if invoice.notes %}
-
Hinweise
+
{{ t('block_notes', lang) }}
{{ invoice.notes | replace('\n', '
') | safe }}
{% endif %} @@ -199,18 +197,18 @@
{{ company.postalCode }} {{ company.city }}
-
Kontakt
+
{{ t('footer_contact', lang) }}
{% if company.email %}
{{ company.email }}
{% endif %} {% if company.phone %}
{{ company.phone }}
{% endif %} {% if company.website %}
{{ company.website }}
{% endif %}
-
Steuer
- {% if company.taxNumber %}
St-Nr. {{ company.taxNumber }}
{% endif %} - {% if company.vatId %}
USt-IdNr. {{ company.vatId }}
{% endif %} +
{{ t('footer_tax', lang) }}
+ {% if company.taxNumber %}
{{ t('footer_tax_no', lang) }} {{ company.taxNumber }}
{% endif %} + {% if company.vatId %}
{{ t('footer_vat_id', lang) }} {{ company.vatId }}
{% endif %}
-
Bankverbindung
+
{{ t('footer_bank', lang) }}
{% if company.bankName %}
{{ company.bankName }}
{% endif %} {% if company.bankIban %}
{{ company.bankIban }}
{% endif %} {% if company.bankBic %}
{{ company.bankBic }}
{% endif %} diff --git a/sidecar/templates/offer.html.j2 b/sidecar/templates/offer.html.j2 index d0acb32..402b90f 100644 --- a/sidecar/templates/offer.html.j2 +++ b/sidecar/templates/offer.html.j2 @@ -1,8 +1,9 @@ - +{% set lang = offer.pdfLanguage or 'de' %} + - Angebot {{ offer.number }} + {{ t('doctype_offer', lang) }} {{ offer.number }} @@ -13,7 +14,7 @@ {% if company.ownerName %}
{{ company.ownerName }}
{% endif %}
-
Angebot
+
{{ t('doctype_offer', lang) }}
{{ offer.number }}
@@ -26,7 +27,7 @@ {%- if company.postalCode %} · {{ company.postalCode }} {{ company.city }}{% endif %}
{{ customer.name }}
- {% if customer.contactPerson %}
z. Hd. {{ customer.contactPerson }}
{% endif %} + {% if customer.contactPerson %}
{{ t('addr_attention', lang) }} {{ customer.contactPerson }}
{% endif %}
{{ customer.street }}
{{ customer.postalCode }} {{ customer.city }}
{% if customer.country and customer.country != "DE" %}
{{ customer.country }}
{% endif %} @@ -34,26 +35,26 @@
- Angebotsnr. + {{ t('meta_offer_no', lang) }} {{ offer.number }}
- Datum + {{ t('meta_offer_date', lang) }} {{ offer.issueDate | date_unix }}
- Gültig bis + {{ t('meta_valid_until', lang) }} {{ offer.validUntil | date_unix }}
{% if offer.servicePeriodStart and offer.servicePeriodEnd %}
- Leistungszeitraum + {{ t('meta_service_period', lang) }} {{ offer.servicePeriodStart | date_unix }} – {{ offer.servicePeriodEnd | date_unix }}
{% endif %} {% if customer.vatId %}
- USt-IdNr. Kunde + {{ t('meta_customer_vatid', lang) }} {{ customer.vatId }}
{% endif %} @@ -69,13 +70,13 @@
Zwischensumme{{ t('totals_subtotal', lang) }} {{ invoice.subtotal | eur(invoice.currency) }}
Umsatzsteuer{{ t('totals_vat', lang) }} {{ invoice.vatAmount | eur(invoice.currency) }}
Gesamtbetrag{{ t('totals_grand', lang) }} {{ invoice.total | eur(invoice.currency) }}
Noch offen{{ t('totals_remaining', lang) }} {{ (invoice.total - invoice.amountPaidCent) | eur(invoice.currency) }}
- - - - - - {% if not offer.isKleinunternehmer and not offer.isReverseCharge %}{% endif %} - + + + + + + {% if not offer.isKleinunternehmer and not offer.isReverseCharge %}{% endif %} + @@ -89,9 +90,9 @@ {% endif %} {% if it.linePeriodStart and it.linePeriodEnd %} {% if it.linePeriodStart == it.linePeriodEnd %} -
Leistungsdatum {{ it.linePeriodStart | date_unix }}
+
{{ t('item_period_single', lang) }} {{ it.linePeriodStart | date_unix }}
{% else %} -
Leistungszeitraum {{ it.linePeriodStart | date_unix }} – {{ it.linePeriodEnd | date_unix }}
+
{{ t('item_period_range', lang) }} {{ it.linePeriodStart | date_unix }} – {{ it.linePeriodEnd | date_unix }}
{% endif %} {% endif %} @@ -108,17 +109,17 @@
Pos.BeschreibungMengeEinheitEinzelpreisUSt %Gesamt{{ t('col_pos', lang) }}{{ t('col_desc', lang) }}{{ t('col_qty', lang) }}{{ t('col_unit', lang) }}{{ t('col_price', lang) }}{{ t('col_vat', lang) }}{{ t('col_total', lang) }}
- + {% if not offer.isKleinunternehmer and not offer.isReverseCharge %} - + {% endif %} - +
Zwischensumme{{ t('totals_subtotal', lang) }} {{ offer.subtotal | eur(offer.currency) }}
Umsatzsteuer{{ t('totals_vat', lang) }} {{ offer.vatAmount | eur(offer.currency) }}
Gesamtbetrag{{ t('totals_grand', lang) }} {{ offer.total | eur(offer.currency) }}
@@ -129,30 +130,25 @@ {% endif %} {% if offer.isReverseCharge %} -

- Bei Auftragsannahme wird die Rechnung mit dem Hinweis "Steuerschuldnerschaft des Leistungsempfängers" ausgestellt (Reverse Charge). -

+

{{ t('offer_rc_hint', lang) }}

{% endif %}

- Dieses Angebot ist freibleibend und unverbindlich. Gültig bis {{ offer.validUntil | date_unix }}. - Bei Annahme erstellen wir eine entsprechende Rechnung. + {{ t('offer_disclaimer', lang, date=offer.validUntil | date_unix) }}

{% if offer.skontoPercent and offer.skontoDays and offer.skontoAmountCent %}
-
Skonto-Angebot
+
{{ t('block_skonto_offer', lang) }}

- Bei Zahlung der späteren Rechnung innerhalb von {{ offer.skontoDays }} Tagen - ab Rechnungsdatum gewähren wir - {{ "%.1f"|format(offer.skontoPercent) }} % Skonto. + {{ t('skonto_offer_text', lang, days=offer.skontoDays, percent='%.1f'|format(offer.skontoPercent)) }}

{% endif %} {% if offer.notes %}
-
Hinweise
+
{{ t('block_notes', lang) }}
{{ offer.notes | replace('\n', '
') | safe }}
{% endif %} @@ -165,15 +161,15 @@
{{ company.postalCode }} {{ company.city }}
-
Kontakt
+
{{ t('footer_contact', lang) }}
{% if company.email %}
{{ company.email }}
{% endif %} {% if company.phone %}
{{ company.phone }}
{% endif %} {% if company.website %}
{{ company.website }}
{% endif %}
-
Steuer
- {% if company.taxNumber %}
St-Nr. {{ company.taxNumber }}
{% endif %} - {% if company.vatId %}
USt-IdNr. {{ company.vatId }}
{% endif %} +
{{ t('footer_tax', lang) }}
+ {% if company.taxNumber %}
{{ t('footer_tax_no', lang) }} {{ company.taxNumber }}
{% endif %} + {% if company.vatId %}
{{ t('footer_vat_id', lang) }} {{ company.vatId }}
{% endif %}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aed6476..6d570fe 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6876,7 +6876,7 @@ dependencies = [ [[package]] name = "zettel" -version = "0.15.0" +version = "0.16.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 66401c6..41d5f7c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zettel" -version = "0.15.0" +version = "0.16.0" description = "Zettel — Offline-first invoice generator" authors = ["Jonas Laux"] edition = "2021" diff --git a/src-tauri/src/backup.rs b/src-tauri/src/backup.rs index 43eaf18..a9c5cb0 100644 --- a/src-tauri/src/backup.rs +++ b/src-tauri/src/backup.rs @@ -251,7 +251,7 @@ pub async fn stage_restore( // Muss mit dem Wert in `Settings.svelte`:CURRENT_DB_SCHEMA_VERSION // sowie dem höchsten registrierten Migrations-`version` in `lib.rs` // synchron bleiben. Bei neuer Migration: hier hochzählen. - const CURRENT_SCHEMA: u32 = 24; + const CURRENT_SCHEMA: u32 = 26; if parsed.db_schema_version > CURRENT_SCHEMA { return Err(format!( "Backup-Schema {} ist neuer als die App-Version (Schema {}). Bitte App aktualisieren.", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index caf5055..b053937 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -155,6 +155,18 @@ fn build_migrations() -> Vec { sql: include_str!("../../src/lib/db/migrations/0023_v0.15_auto_backup.sql"), kind: MigrationKind::Up, }, + Migration { + version: 25, + description: "v0.16_pdf_language", + sql: include_str!("../../src/lib/db/migrations/0024_v0.16_pdf_language.sql"), + kind: MigrationKind::Up, + }, + Migration { + version: 26, + description: "v0.16_expense_categories", + sql: include_str!("../../src/lib/db/migrations/0025_v0.16_expense_categories.sql"), + kind: MigrationKind::Up, + }, ] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 52ccace..d11d903 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Zettel", - "version": "0.15.0", + "version": "0.16.0", "identifier": "digital.laux.zettel", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/lib/db/expense-categories.ts b/src/lib/db/expense-categories.ts new file mode 100644 index 0000000..dec2102 --- /dev/null +++ b/src/lib/db/expense-categories.ts @@ -0,0 +1,95 @@ +import { execute, select } from "./client"; +import type { ExpenseCategory } from "./schema"; + +type CategoryRow = { + id: number; + name: string; + datev_account_skr03: string | null; + datev_account_skr04: string | null; + builtin: number; + archived: number; + sort_order: number; + created_at: number; +}; + +function mapCategory(r: CategoryRow): ExpenseCategory { + return { + id: r.id, + name: r.name, + datevAccountSkr03: r.datev_account_skr03, + datevAccountSkr04: r.datev_account_skr04, + builtin: r.builtin === 1, + archived: r.archived === 1, + sortOrder: r.sort_order, + createdAt: r.created_at, + }; +} + +export type CategoryInput = Omit; + +export async function listCategories(opts: { includeArchived?: boolean } = {}): Promise { + const where = opts.includeArchived ? "" : "WHERE archived = 0"; + const rows = await select( + `SELECT * FROM expense_categories ${where} ORDER BY sort_order ASC, name COLLATE NOCASE ASC`, + ); + return rows.map(mapCategory); +} + +export async function getCategory(id: number): Promise { + const rows = await select("SELECT * FROM expense_categories WHERE id = ?", [id]); + return rows.length ? mapCategory(rows[0]) : null; +} + +export async function createCategory(input: CategoryInput): Promise { + const res = await execute( + `INSERT INTO expense_categories + (name, datev_account_skr03, datev_account_skr04, builtin, archived, sort_order) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + input.name, + input.datevAccountSkr03 ?? null, + input.datevAccountSkr04 ?? null, + input.builtin ? 1 : 0, + input.archived ? 1 : 0, + input.sortOrder ?? 0, + ], + ); + return Number(res.lastInsertId ?? 0); +} + +export async function updateCategory(id: number, input: CategoryInput): Promise { + await execute( + `UPDATE expense_categories SET + name = ?, datev_account_skr03 = ?, datev_account_skr04 = ?, + archived = ?, sort_order = ? + WHERE id = ?`, + [ + input.name, + input.datevAccountSkr03 ?? null, + input.datevAccountSkr04 ?? null, + input.archived ? 1 : 0, + input.sortOrder ?? 0, + id, + ], + ); +} + +export async function deleteCategory(id: number): Promise { + await execute("DELETE FROM expense_categories WHERE id = ?", [id]); +} + +export async function setCategoryArchived(id: number, archived: boolean): Promise { + await execute( + "UPDATE expense_categories SET archived = ? WHERE id = ?", + [archived ? 1 : 0, id], + ); +} + +/** Resolve the DATEV account for a given category and SKR profile. Returns + * `null` when no override is configured — caller falls back to its default. */ +export function accountForSkr( + cat: ExpenseCategory, + skr: "SKR03" | "SKR04", +): string | null { + return skr === "SKR03" ? cat.datevAccountSkr03 : cat.datevAccountSkr04; +} diff --git a/src/lib/db/expenses.ts b/src/lib/db/expenses.ts index 6451115..2d627a7 100644 --- a/src/lib/db/expenses.ts +++ b/src/lib/db/expenses.ts @@ -40,6 +40,7 @@ type ExpenseItemRow = { position: number; description: string; category: string | null; + category_id: number | null; datev_account: string | null; quantity: number; unit: string; @@ -83,6 +84,7 @@ function mapItem(r: ExpenseItemRow): ExpenseItem { position: r.position, description: r.description, category: r.category, + categoryId: r.category_id, datevAccount: r.datev_account, quantity: r.quantity, unit: r.unit, @@ -95,6 +97,7 @@ function mapItem(r: ExpenseItemRow): ExpenseItem { export type ExpenseItemInput = { description: string; category: string | null; + categoryId?: number | null; datevAccount: string | null; quantity: number; unit: string; @@ -247,14 +250,15 @@ async function writeItems(expenseId: number, items: ExpenseItemInput[]): Promise const line = computeLineTotal(it); await execute( `INSERT INTO expense_items - (expense_id, position, description, category, datev_account, + (expense_id, position, description, category, category_id, datev_account, quantity, unit, unit_price, vat_rate, line_total) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ expenseId, i + 1, it.description, it.category, + it.categoryId ?? null, it.datevAccount, it.quantity, it.unit, diff --git a/src/lib/db/invoices.ts b/src/lib/db/invoices.ts index d2b5db3..7f84842 100644 --- a/src/lib/db/invoices.ts +++ b/src/lib/db/invoices.ts @@ -47,6 +47,7 @@ type InvoiceRow = { skonto_percent: number | null; skonto_days: number | null; amount_paid_cent: number | null; + pdf_language: string | null; }; type InvoiceItemRow = { @@ -102,6 +103,7 @@ function mapInvoice(r: InvoiceRow): Invoice { skontoPercent: r.skonto_percent, skontoDays: r.skonto_days, amountPaidCent: r.amount_paid_cent ?? 0, + pdfLanguage: (r.pdf_language ?? "de") as Invoice["pdfLanguage"], }; } @@ -157,6 +159,7 @@ export type InvoiceFormInput = { servicePeriodEnd?: number | null; skontoPercent?: number | null; skontoDays?: number | null; + pdfLanguage?: "de" | "en"; }; // --- Totals --- @@ -372,8 +375,8 @@ export async function createInvoice(input: InvoiceFormInput): Promise { status, subtotal, vat_amount, total, is_kleinunternehmer, is_reverse_charge, reverse_charge_type, notes, payment_terms, is_credit_note, corrects_invoice_id, currency, exchange_rate, eur_total_cent, notes_internal, follow_up_date, - service_period_start, service_period_end, skonto_percent, skonto_days) - VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + service_period_start, service_period_end, skonto_percent, skonto_days, pdf_language) + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ number, input.customerId, @@ -400,6 +403,7 @@ export async function createInvoice(input: InvoiceFormInput): Promise { input.servicePeriodEnd ?? null, input.isCreditNote ? null : input.skontoPercent ?? null, input.isCreditNote ? null : input.skontoDays ?? null, + input.pdfLanguage ?? settings.defaultPdfLanguage, ], ); const id = Number(res.lastInsertId ?? 0); @@ -440,6 +444,7 @@ export async function updateInvoice( notes_internal = ?, follow_up_date = ?, service_period_start = ?, service_period_end = ?, skonto_percent = ?, skonto_days = ?, + pdf_language = ?, updated_at = unixepoch() WHERE id = ?`, [ @@ -464,6 +469,7 @@ export async function updateInvoice( input.servicePeriodEnd ?? null, existing.invoice.isCreditNote ? null : input.skontoPercent ?? null, existing.invoice.isCreditNote ? null : input.skontoDays ?? null, + input.pdfLanguage ?? existing.invoice.pdfLanguage, id, ], ); diff --git a/src/lib/db/migrations/0024_v0.16_pdf_language.sql b/src/lib/db/migrations/0024_v0.16_pdf_language.sql new file mode 100644 index 0000000..9143c97 --- /dev/null +++ b/src/lib/db/migrations/0024_v0.16_pdf_language.sql @@ -0,0 +1,12 @@ +-- v0.16: PDF-Sprache pro Rechnung / Angebot. +-- +-- Default 'de' für alle bestehenden Datensätze — Verhalten ist identisch zu +-- v0.15. Beim Anlegen neuer Rechnungen wird der Settings-Default vorbelegt. +-- ZUGFeRD-XML ist data-only und bleibt sprach-neutral; nur das visuelle PDF +-- wird übersetzt (Labels, Hinweistexte, Tabellenköpfe). + +ALTER TABLE invoices ADD COLUMN pdf_language TEXT NOT NULL DEFAULT 'de'; +ALTER TABLE offers ADD COLUMN pdf_language TEXT NOT NULL DEFAULT 'de'; +ALTER TABLE settings ADD COLUMN default_pdf_language TEXT NOT NULL DEFAULT 'de'; + +PRAGMA user_version = 25; diff --git a/src/lib/db/migrations/0025_v0.16_expense_categories.sql b/src/lib/db/migrations/0025_v0.16_expense_categories.sql new file mode 100644 index 0000000..ac27bd6 --- /dev/null +++ b/src/lib/db/migrations/0025_v0.16_expense_categories.sql @@ -0,0 +1,41 @@ +-- v0.16: Kuratierte Ausgaben-Kategorien mit SKR03/SKR04-Konten-Mapping. +-- +-- Bestehende Freitext-Spalte expense_items.category bleibt als Legacy-Label +-- erhalten (Backfill-Pfad). Neue category_id-Spalte verweist optional auf +-- die kuratierte Liste — wenn gesetzt, wird beim DATEV-Export das Konto aus +-- der Tabelle gezogen (statt aus expense_items.datev_account). +-- +-- builtin = 1 markiert vom App-Default eingespielte Einträge. User dürfen +-- builtin-Einträge bearbeiten, aber wir können sie bei einem späteren Reset +-- wiedererkennen. archived versteckt sie im Picker, ohne sie zu löschen. + +CREATE TABLE IF NOT EXISTS expense_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + datev_account_skr03 TEXT, + datev_account_skr04 TEXT, + builtin INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE INDEX IF NOT EXISTS idx_expense_categories_archived ON expense_categories(archived); + +-- Standard-Kategorien für Solo-Freelancer. +-- Konten gemäß DATEV SKR03/SKR04 (siehe docs/datev-export.md). +INSERT INTO expense_categories (name, datev_account_skr03, datev_account_skr04, builtin, sort_order) VALUES + ('Software / SaaS', '4806', '6815', 1, 10), + ('Hardware (Sofortabschreibung GWG)', '0480', '0670', 1, 20), + ('Telefon / Internet', '4920', '6805', 1, 30), + ('Reisekosten', '4660', '6660', 1, 40), + ('Fortbildung', '4946', '6821', 1, 50), + ('Bürobedarf', '4930', '6815', 1, 60), + ('Honorare / Subunternehmer', '4783', '6300', 1, 70), + ('Werbung / Marketing', '4610', '6600', 1, 80), + ('Bankgebühren', '4970', '6855', 1, 90), + ('Sonstige Betriebsausgaben', '4980', '6850', 1, 100); + +ALTER TABLE expense_items ADD COLUMN category_id INTEGER; + +PRAGMA user_version = 26; diff --git a/src/lib/db/offers.ts b/src/lib/db/offers.ts index d6f689b..86b8fa6 100644 --- a/src/lib/db/offers.ts +++ b/src/lib/db/offers.ts @@ -40,6 +40,7 @@ type OfferRow = { service_period_end: number | null; skonto_percent: number | null; skonto_days: number | null; + pdf_language: string | null; }; type OfferItemRow = { @@ -86,6 +87,7 @@ function mapOffer(r: OfferRow): Offer { servicePeriodEnd: r.service_period_end, skontoPercent: r.skonto_percent, skontoDays: r.skonto_days, + pdfLanguage: (r.pdf_language ?? "de") as Offer["pdfLanguage"], }; } @@ -131,6 +133,7 @@ export type OfferFormInput = { servicePeriodEnd?: number | null; skontoPercent?: number | null; skontoDays?: number | null; + pdfLanguage?: "de" | "en"; }; export function computeLineTotal(item: OfferItemInput): number { @@ -307,8 +310,8 @@ export async function createOffer(input: OfferFormInput): Promise { (number, customer_id, customer_snapshot, issue_date, valid_until, status, subtotal, vat_amount, total, is_kleinunternehmer, is_reverse_charge, notes, intro_text, currency, exchange_rate, - service_period_start, service_period_end, skonto_percent, skonto_days) - VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + service_period_start, service_period_end, skonto_percent, skonto_days, pdf_language) + VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ number, input.customerId, @@ -328,6 +331,7 @@ export async function createOffer(input: OfferFormInput): Promise { input.servicePeriodEnd ?? null, input.skontoPercent ?? null, input.skontoDays ?? null, + input.pdfLanguage ?? settings.defaultPdfLanguage, ], ); const id = Number(res.lastInsertId ?? 0); @@ -364,6 +368,7 @@ export async function updateOffer( currency = ?, exchange_rate = ?, service_period_start = ?, service_period_end = ?, skonto_percent = ?, skonto_days = ?, + pdf_language = ?, updated_at = unixepoch() WHERE id = ?`, [ @@ -383,6 +388,7 @@ export async function updateOffer( input.servicePeriodEnd ?? null, input.skontoPercent ?? null, input.skontoDays ?? null, + input.pdfLanguage ?? existing.offer.pdfLanguage, id, ], ); @@ -496,6 +502,7 @@ export async function convertToInvoice( servicePeriodEnd: data.offer.servicePeriodEnd, skontoPercent: data.offer.skontoPercent, skontoDays: data.offer.skontoDays, + pdfLanguage: data.offer.pdfLanguage, }; const invoiceId = await createInvoice(invoiceInput); diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts index f01337f..4cd517a 100644 --- a/src/lib/db/queries.ts +++ b/src/lib/db/queries.ts @@ -61,6 +61,7 @@ type SettingsRow = { default_skonto_days: number | null; auto_backup_interval_days: number | null; last_auto_backup_at: number | null; + default_pdf_language: string | null; created_at: number; updated_at: number; }; @@ -124,6 +125,7 @@ function mapSettings(r: SettingsRow): Settings { defaultSkontoDays: r.default_skonto_days ?? 7, autoBackupIntervalDays: r.auto_backup_interval_days ?? 0, lastAutoBackupAt: r.last_auto_backup_at, + defaultPdfLanguage: (r.default_pdf_language ?? "de") as Settings["defaultPdfLanguage"], createdAt: r.created_at, updatedAt: r.updated_at, }; @@ -199,6 +201,7 @@ export async function saveSettings(s: Partial): Promise { default_skonto_days = ?, auto_backup_interval_days = ?, last_auto_backup_at = ?, + default_pdf_language = ?, updated_at = unixepoch() WHERE id = 1`, [ @@ -258,6 +261,7 @@ export async function saveSettings(s: Partial): Promise { s.defaultSkontoDays ?? 7, s.autoBackupIntervalDays ?? 0, s.lastAutoBackupAt ?? null, + s.defaultPdfLanguage ?? "de", ], ); } diff --git a/src/lib/db/reminders.ts b/src/lib/db/reminders.ts index 6506b4a..5c98d70 100644 --- a/src/lib/db/reminders.ts +++ b/src/lib/db/reminders.ts @@ -202,6 +202,7 @@ export async function listOverdueInvoices(): Promise { skontoPercent: null, skontoDays: null, amountPaidCent: 0, + pdfLanguage: "de" as const, }, customerName: r.customer_name ?? "—", daysOverdue: Math.max(0, Math.floor((now - r.due_date) / 86400)), diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 495172b..eefeeaa 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -95,6 +95,7 @@ export const settings = sqliteTable("settings", { defaultSkontoDays: integer("default_skonto_days").notNull().default(7), autoBackupIntervalDays: integer("auto_backup_interval_days").notNull().default(0), lastAutoBackupAt: integer("last_auto_backup_at"), + defaultPdfLanguage: text("default_pdf_language", { enum: ["de", "en"] }).notNull().default("de"), createdAt: integer("created_at") .notNull() .default(sql`(unixepoch())`), @@ -182,6 +183,7 @@ export const invoices = sqliteTable("invoices", { skontoPercent: real("skonto_percent"), skontoDays: integer("skonto_days"), amountPaidCent: integer("amount_paid_cent").notNull().default(0), + pdfLanguage: text("pdf_language", { enum: ["de", "en"] }).notNull().default("de"), }); export const invoicePayments = sqliteTable("invoice_payments", { @@ -199,6 +201,22 @@ export type InvoicePayment = typeof invoicePayments.$inferSelect; export type InvoicePaymentInsert = typeof invoicePayments.$inferInsert; export type InvoicePaymentSource = InvoicePayment["source"]; +export type PdfLanguage = "de" | "en"; + +export const expenseCategories = sqliteTable("expense_categories", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + datevAccountSkr03: text("datev_account_skr03"), + datevAccountSkr04: text("datev_account_skr04"), + builtin: integer("builtin", { mode: "boolean" }).notNull().default(false), + archived: integer("archived", { mode: "boolean" }).notNull().default(false), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: integer("created_at").notNull().default(sql`(unixepoch())`), +}); + +export type ExpenseCategory = typeof expenseCategories.$inferSelect; +export type ExpenseCategoryInsert = typeof expenseCategories.$inferInsert; + export const invoiceItems = sqliteTable("invoice_items", { id: integer("id").primaryKey({ autoIncrement: true }), invoiceId: integer("invoice_id").notNull(), @@ -294,6 +312,7 @@ export const offers = sqliteTable("offers", { servicePeriodEnd: integer("service_period_end"), skontoPercent: real("skonto_percent"), skontoDays: integer("skonto_days"), + pdfLanguage: text("pdf_language", { enum: ["de", "en"] }).notNull().default("de"), }); export const offerItems = sqliteTable("offer_items", { @@ -382,6 +401,7 @@ export const expenseItems = sqliteTable("expense_items", { position: integer("position").notNull(), description: text("description").notNull().default(""), category: text("category"), + categoryId: integer("category_id"), datevAccount: text("datev_account"), quantity: real("quantity").notNull().default(1), unit: text("unit").notNull().default("Stk"), diff --git a/src/lib/sidecar/invoice.ts b/src/lib/sidecar/invoice.ts index 1f2a349..a77a976 100644 --- a/src/lib/sidecar/invoice.ts +++ b/src/lib/sidecar/invoice.ts @@ -74,6 +74,7 @@ function buildPayload(opts: { skontoAmountCent: skonto?.discountCent ?? null, skontoDeadline: skonto?.deadlineUnix ?? null, amountPaidCent: invoice.amountPaidCent ?? 0, + pdfLanguage: invoice.pdfLanguage ?? "de", }, items: items.map((it) => ({ position: it.position, diff --git a/src/lib/sidecar/offer.ts b/src/lib/sidecar/offer.ts index 77341d2..f755cc2 100644 --- a/src/lib/sidecar/offer.ts +++ b/src/lib/sidecar/offer.ts @@ -49,6 +49,7 @@ function buildPayload(opts: { skontoDays: skonto?.days ?? null, skontoAmountCent: skonto?.discountCent ?? null, skontoDeadline: skonto?.deadlineUnix ?? null, + pdfLanguage: offer.pdfLanguage ?? "de", }, items: items.map((it) => ({ position: it.position, diff --git a/src/lib/ui/CatalogPicker.svelte b/src/lib/ui/CatalogPicker.svelte index 5143c45..ace362a 100644 --- a/src/lib/ui/CatalogPicker.svelte +++ b/src/lib/ui/CatalogPicker.svelte @@ -12,14 +12,22 @@ onPick?: (item: CatalogItem) => void; /** "invoice" liefert defaultDatevAccount nicht — nur expense-Edit braucht es. */ context?: "invoice" | "offer" | "recurring" | "expense"; + /** Sprache der aktiven Rechnung/Angebot — entscheidet welche Beschreibung der Picker zeigt. */ + language?: "de" | "en"; }; let { open = $bindable(false), onOpenChange, onPick, context = "invoice", + language = "de", }: Props = $props(); + function descriptionFor(item: CatalogItem): string { + if (language === "en" && item.descriptionEn) return item.descriptionEn; + return item.descriptionDe ?? ""; + } + let query = $state(""); let results = $state([]); let activeIndex = $state(0); @@ -145,8 +153,8 @@ {item.tags} {/if} - {#if item.descriptionDe} -
{item.descriptionDe}
+ {#if descriptionFor(item)} +
{descriptionFor(item)}
{/if}
diff --git a/src/lib/utils/auto-backup.ts b/src/lib/utils/auto-backup.ts index 60fb950..e333de2 100644 --- a/src/lib/utils/auto-backup.ts +++ b/src/lib/utils/auto-backup.ts @@ -19,7 +19,7 @@ function weekdaySlug(date: Date): string { return WEEKDAY_SLUGS[date.getDay()]; } -const CURRENT_DB_SCHEMA_VERSION = 24; +const CURRENT_DB_SCHEMA_VERSION = 26; export async function maybeRunAutoBackup(): Promise<{ ran: boolean; reason: string }> { let settings; diff --git a/src/routes/ExpenseEdit.svelte b/src/routes/ExpenseEdit.svelte index 469e0ac..8bdc2fd 100644 --- a/src/routes/ExpenseEdit.svelte +++ b/src/routes/ExpenseEdit.svelte @@ -15,7 +15,8 @@ type ExpenseFormInput, type ExpenseItemInput, } from "$lib/db/expenses"; - import type { Expense, ExpenseStatus, Vendor } from "$lib/db/schema"; + import type { Expense, ExpenseStatus, Vendor, ExpenseCategory } from "$lib/db/schema"; + import { listCategories as listCuratedCategories, accountForSkr } from "$lib/db/expense-categories"; import { centsToEur, eurStringToCents } from "$lib/utils/money"; import { fromIsoDate, nowUnix, toIsoDate } from "$lib/utils/date"; import { @@ -67,6 +68,7 @@ let vendors = $state([]); let categories = $state([]); + let curatedCategories = $state([]); let id = $state(null); let existing = $state(null); let loaded = $state(false); @@ -85,6 +87,7 @@ { description: "", category: null, + categoryId: null, datevAccount: null, quantity: 1, unit: "Stk", @@ -106,14 +109,41 @@ let validationFindingsCount = $state(null); $effect(() => { - Promise.all([listVendors(), listCategories()]) - .then(([vs, cs]) => { + Promise.all([listVendors(), listCategories(), listCuratedCategories()]) + .then(([vs, cs, curated]) => { vendors = vs; categories = cs; + curatedCategories = curated; }) .catch((e) => (error = String(e))); }); + const curatedCategoryItems = $derived([ + { value: "", label: "— frei eintragen —" }, + ...curatedCategories.map((c) => ({ value: String(c.id), label: c.name })), + ]); + + function pickCuratedCategory(idx: number, valueStr: string) { + const it = items[idx]; + if (valueStr === "") { + it.categoryId = null; + // Freitext-Feld bleibt unangetastet — User kann manuell tippen. + } else { + const cId = Number.parseInt(valueStr, 10); + const cat = curatedCategories.find((c) => c.id === cId); + if (cat) { + it.categoryId = cat.id; + it.category = cat.name; + // DATEV-Konto vorbefüllen wenn leer (SKR03-Default — DATEV-Export + // löst pro Beleg über categoryId neu auf, das hier ist nur UI-Hint). + if (!it.datevAccount) { + it.datevAccount = accountForSkr(cat, "SKR03"); + } + } + } + items = [...items]; + } + // Prefill default category from selected vendor when creating a new expense. const selectedVendor = $derived( vendorIdStr ? vendors.find((v) => String(v.id) === vendorIdStr) ?? null : null, @@ -153,6 +183,7 @@ items = res.items.map((it) => ({ description: it.description, category: it.category, + categoryId: it.categoryId ?? null, datevAccount: it.datevAccount, quantity: it.quantity, unit: it.unit, @@ -172,6 +203,7 @@ { description: "", category: selectedVendor?.defaultCategory ?? null, + categoryId: null, datevAccount: null, quantity: 1, unit: "Stk", @@ -189,6 +221,7 @@ { description: it.name + (it.descriptionDe ? ` — ${it.descriptionDe}` : ""), category: selectedVendor?.defaultCategory ?? null, + categoryId: null, datevAccount: it.defaultDatevAccount ?? null, quantity: 1, unit: it.unit, @@ -289,6 +322,7 @@ items = data.lineItems.map((li) => ({ description: li.description, category: matched?.defaultCategory ?? null, + categoryId: null, datevAccount: null, quantity: li.quantity, unit: li.unit, @@ -478,6 +512,7 @@ items: items.map((it) => ({ description: it.description, category: it.category?.trim() || null, + categoryId: it.categoryId ?? null, datevAccount: it.datevAccount?.trim() || null, quantity: it.quantity, unit: it.unit, @@ -711,12 +746,23 @@ - +
+ + {/if} +
([]); let vendors = $state([]); @@ -109,6 +110,45 @@ dueDate: (r) => r.dueDate, }), ); + + let selectedIds = $state>(new Set()); + let bulkBusy = $state(false); + let bulkDeleteConfirmOpen = $state(false); + + const selectedExpenses = $derived(sortedExpenses.filter((e) => selectedIds.has(e.id))); + const canBulkMarkPaid = $derived( + selectedExpenses.length > 0 && selectedExpenses.every((e) => e.status === "open"), + ); + const canBulkDelete = $derived( + selectedExpenses.length > 0 && + selectedExpenses.every((e) => e.status === "draft" || e.status === "cancelled"), + ); + + function toggleSelect(id: number) { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + selectedIds = next; + } + function toggleSelectAll() { + if (selectedIds.size === sortedExpenses.length) selectedIds = new Set(); + else selectedIds = new Set(sortedExpenses.map((e) => e.id)); + } + + async function bulkAction(label: string, fn: (id: number) => Promise) { + bulkBusy = true; + try { + for (const e of selectedExpenses) await fn(e.id); + toast.success(`${selectedExpenses.length} ${label}`); + selectedIds = new Set(); + await reload(); + } catch (e) { + toast.error("Bulk-Aktion fehlgeschlagen", String(e)); + } finally { + bulkBusy = false; + bulkDeleteConfirmOpen = false; + } + }
@@ -161,6 +201,13 @@ + Interne Nr.Beleg-Nr.Datum @@ -176,6 +223,13 @@ class="border-t hover:bg-muted/30 cursor-pointer transition-colors" onclick={() => push(`/expenses/${e.id}`)} > + @@ -193,3 +247,27 @@
+ 0 && selectedIds.size === sortedExpenses.length} + onCheckedChange={toggleSelectAll} + aria-label="Alle auswählen" + /> + ev.stopPropagation()}> + toggleSelect(e.id)} + aria-label={`Eingangsrechnung ${e.internalNumber} auswählen`} + /> + {e.internalNumber} {e.number ?? "—"} {formatDate(e.issueDate)}
{/if} + + (selectedIds = new Set())}> + {#if canBulkMarkPaid} + + {/if} + {#if canBulkDelete} + + {/if} + + + bulkAction('gelöscht', deleteExpense)} +/> diff --git a/src/routes/Export.svelte b/src/routes/Export.svelte index fb0cf22..72bbe1b 100644 --- a/src/routes/Export.svelte +++ b/src/routes/Export.svelte @@ -18,6 +18,7 @@ } from "$lib/ui"; import { loadInvoicesForExport } from "$lib/db/invoices"; import { loadExpensesForExport } from "$lib/db/expenses"; + import { listCategories as listCuratedCategories, accountForSkr } from "$lib/db/expense-categories"; import { ACCOUNT_MAPS, buildDatevCsv, @@ -60,9 +61,31 @@ const from = fromIsoDate(dateFromIso); const toEnd = fromIsoDate(dateToIso) + 86399; // ganzen Tag inkludieren const invoices = includeInvoices ? await loadInvoicesForExport(from, toEnd) : []; - const expenses = includeExpenses + const rawExpenses = includeExpenses ? await loadExpensesForExport(from, toEnd, { includeCancelled }) : []; + // Curated-Category-Override: items mit categoryId bekommen das + // SKR-spezifische Konto. Explizites it.datevAccount (Override) gewinnt + // weiter — der Picker setzt es aber nur als SKR03-Default, also wenn + // der User SKR04 exportiert ohne den Wert manuell zu überschreiben, + // nimmt der Resolver hier das SKR04-Konto aus der Kategorie. + const categoriesById = new Map>[number]>(); + if (includeExpenses) { + const cats = await listCuratedCategories({ includeArchived: true }); + for (const c of cats) categoriesById.set(c.id, c); + } + const expenses = rawExpenses.map((e) => ({ + ...e, + items: e.items.map((it) => { + if (it.categoryId && !it.datevAccount) { + const c = categoriesById.get(it.categoryId); + if (c) { + return { ...it, datevAccount: accountForSkr(c, skr) }; + } + } + return it; + }), + })); const total = invoices.length + expenses.length; if (total === 0) { toast.error( diff --git a/src/routes/InvoiceEdit.svelte b/src/routes/InvoiceEdit.svelte index b9e444d..5b9698f 100644 --- a/src/routes/InvoiceEdit.svelte +++ b/src/routes/InvoiceEdit.svelte @@ -107,6 +107,12 @@ let skontoEnabled = $state(false); let skontoPercent = $state(2); let skontoDays = $state(7); + let pdfLanguage = $state<"de" | "en">("de"); + + const pdfLanguageItems = [ + { value: "de", label: "Deutsch" }, + { value: "en", label: "Englisch" }, + ]; async function fetchEcbRate() { if (currency === "EUR") return; @@ -190,6 +196,7 @@ skontoPercent = s.defaultSkontoPercent; skontoDays = s.defaultSkontoDays; } + pdfLanguage = s.defaultPdfLanguage; } }) .catch((e) => (error = String(e))); @@ -236,6 +243,7 @@ skontoPercent = res.invoice.skontoPercent; skontoDays = res.invoice.skontoDays; } + pdfLanguage = (res.invoice.pdfLanguage ?? "de") as "de" | "en"; items = res.items.map((it) => { const isSingle = !!it.linePeriodStart && @@ -276,8 +284,9 @@ next.unit = it.unit; next.unitPrice = it.defaultUnitPrice; next.priceText = (it.defaultUnitPrice / 100).toFixed(2).replace(".", ","); - if (it.descriptionDe) { - next.longDescription = it.descriptionDe; + const desc = pdfLanguage === "en" && it.descriptionEn ? it.descriptionEn : it.descriptionDe; + if (desc) { + next.longDescription = desc; next.showDetail = true; } items = [...items, next]; @@ -430,6 +439,7 @@ servicePeriodEnd: usePeriod ? fromIsoDate(servicePeriodEndIso) : null, skontoPercent: skontoEnabled && !isCreditNote ? skontoPercent : null, skontoDays: skontoEnabled && !isCreditNote ? skontoDays : null, + pdfLanguage, }; let savedId: number; if (mode === "new") { @@ -822,6 +832,19 @@ +
+ + (pdfLanguage = v as "de" | "en")} + disabled={readOnly} + /> +
+