diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59b7c5eb..759b50e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,8 @@ import { ClientsLayout } from './components/admin/ClientsLayout'; import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout'; import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage'; +import { ChartOfAccountsPage } from './pages/admin/accounting/ChartOfAccountsPage'; +import { LedgerExportPage } from './pages/admin/accounting/LedgerExportPage'; import { RequireFeature } from './components/admin/RequireFeature'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -278,7 +280,11 @@ function App() { }> } /> + } /> + {/* Chart of accounts + VAT codes (Layer A) — gated by + the accounting master flag alongside the section. */} + } /> } /> diff --git a/frontend/src/components/admin/AccountingLayout.tsx b/frontend/src/components/admin/AccountingLayout.tsx index 996c84ec..ca354fa5 100644 --- a/frontend/src/components/admin/AccountingLayout.tsx +++ b/frontend/src/components/admin/AccountingLayout.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Landmark, Calculator, Inbox, Wallet } from 'lucide-react'; +import { Landmark, Calculator, Inbox, Wallet, BookOpen, FileSpreadsheet } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; @@ -50,7 +50,21 @@ export const AccountingLayout: React.FC = () => { icon: Calculator, featureFlag: 'taxReport', }, - // Future: expenses ledger, Erfolgsrechnung. + { + key: 'export', + to: '/admin/accounting/export', + label: t('accounting.subnav.export', 'Treuhänder export'), + icon: FileSpreadsheet, + featureFlag: 'taxReport', + }, + { + key: 'ledger', + to: '/admin/accounting/ledger', + label: t('accounting.subnav.chartOfAccounts', 'Chart of accounts'), + icon: BookOpen, + featureFlag: 'accounting', + }, + // Future: Erfolgsrechnung (Layer B). ]; const enabledItems = navItems.filter((item) => flags[item.featureFlag]); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f2eeed34..cefc4cdf 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3450,7 +3450,15 @@ "subnav": { "incomingInvoices": "Eingangsrechnungen", "expenses": "Aufwände", - "taxReport": "Steuer" + "taxReport": "Steuer", + "export": "Treuhänder-Export", + "chartOfAccounts": "Kontenplan" + }, + "taxTreatment": { + "domestic": "Inland (mit MWST)", + "reverse_charge_service": "Bezugsteuer (Dienstleistungen)", + "foreign_vat_non_reclaimable": "Ausländische MWST (nicht abziehbar)", + "import_goods": "Wareneinfuhr" }, "disposition": { "rebill": "An Kunde weiterverrechnen", @@ -3757,6 +3765,56 @@ } } }, + "ledger": { + "intro": "Dient nur dem Treuhänder-Export — picpeak führt keine doppelte Buchhaltung. Der vorbelegte Kontenplan + die MWST-Codes folgen dem Schweizer/LI KMU-Kontenrahmen; passe sie an das Setup deines Treuhänders an.", + "settingsSaved": "Zuordnungen gespeichert.", + "saveDefaults": "Zuordnungen speichern", + "accountType": { + "asset": "Aktiven", "liability": "Passiven", "equity": "Eigenkapital", "revenue": "Ertrag", "expense": "Aufwand" + }, + "vatDirection": { + "input": "Vorsteuer", "output": "Umsatzsteuer" + }, + "defaults": { + "title": "Standard- & Systemkonten", + "none": "— keines —", + "ledger_account_debitoren": "Forderungen (Debitoren)", + "ledger_account_kreditoren": "Verbindlichkeiten (Kreditoren)", + "ledger_account_bank": "Bank", + "ledger_account_cash": "Kasse", + "ledger_account_default_revenue": "Standard-Ertragskonto", + "ledger_account_default_expense": "Standard-Aufwandkonto", + "ledger_account_mileage": "Kilometer-Konto", + "ledger_account_per_diem": "Spesenpauschalen-Konto", + "ledger_account_rebilled_revenue": "Weiterverrechnete Spesen (Ertrag)" + }, + "vatMap": { "title": "MWST-Code nach steuerlicher Behandlung (Kosten)" }, + "outputVatMap": { "title": "MWST-Code nach Umsatzsatz" }, + "categoryMap": { "title": "Aufwandkategorie → Konto" }, + "accounts": { "title": "Kontenplan" }, + "vatCodes": { "title": "MWST-Codes" }, + "account": { + "addTitle": "Konto hinzufügen", "editTitle": "Konto bearbeiten", + "number": "Kontonummer", "name": "Name", "type": "Art", + "confirmDelete": "Dieses Konto löschen?" + }, + "vat": { + "addTitle": "MWST-Code hinzufügen", "editTitle": "MWST-Code bearbeiten", + "code": "Code", "name": "Name", "rate": "Satz %", "direction": "Richtung", + "account": "MWST-Konto", "noAccount": "— keines —", "confirmDelete": "Diesen MWST-Code löschen?" + }, + "export": { + "title": "Treuhänder-Export", + "intro": "Lade das Sammeljournal (Erträge + Kosten als periodengerechte Buchungssätze mit Konto- und MWST-Codes) zum Import in die Buchhaltungssoftware deines Treuhänders herunter.", + "format": "Zielsoftware", + "format_generic": "Generisch (alle Spalten)", + "format_banana": "Banana Buchhaltung", + "format_bexio": "bexio", + "download": "CSV herunterladen", + "failed": "Export fehlgeschlagen.", + "disclaimer": "Nur periodengerecht (Belegdaten) — Zahlungen/Bankbewegungen sind nicht enthalten. Konto- + MWST-Codes folgen deiner Kontenplan-Zuordnung. Den Import vor der Einreichung immer mit dem Treuhänder prüfen." + } + }, "taxReport": { "title": "Steuerliste", "intro": "Periodenbezogene Umsatzliste mit Netto- und MwSt-Aufschlüsselung, gruppiert nach MwSt-Satz. Stornierte Rechnungen bleiben für die lückenlose Nummernfolge sichtbar, sind aber nicht in den Summen enthalten.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index feeb1ad5..05a2d52a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3450,7 +3450,15 @@ "subnav": { "incomingInvoices": "Incoming invoices", "expenses": "Expenses", - "taxReport": "Tax" + "taxReport": "Tax", + "export": "Treuhänder export", + "chartOfAccounts": "Chart of accounts" + }, + "taxTreatment": { + "domestic": "Domestic (with VAT)", + "reverse_charge_service": "Reverse charge (services)", + "foreign_vat_non_reclaimable": "Foreign VAT (non-reclaimable)", + "import_goods": "Import of goods" }, "disposition": { "rebill": "Re-bill to client", @@ -3757,6 +3765,56 @@ } } }, + "ledger": { + "intro": "Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.", + "settingsSaved": "Mappings saved.", + "saveDefaults": "Save mappings", + "accountType": { + "asset": "Asset", "liability": "Liability", "equity": "Equity", "revenue": "Revenue", "expense": "Expense" + }, + "vatDirection": { + "input": "Input (Vorsteuer)", "output": "Output (Umsatzsteuer)" + }, + "defaults": { + "title": "Default & system accounts", + "none": "— none —", + "ledger_account_debitoren": "Accounts receivable (Debitoren)", + "ledger_account_kreditoren": "Accounts payable (Kreditoren)", + "ledger_account_bank": "Bank", + "ledger_account_cash": "Cash", + "ledger_account_default_revenue": "Default revenue account", + "ledger_account_default_expense": "Default expense account", + "ledger_account_mileage": "Mileage account", + "ledger_account_per_diem": "Per-diem account", + "ledger_account_rebilled_revenue": "Re-billed expenses (revenue)" + }, + "vatMap": { "title": "VAT code by tax treatment (costs)" }, + "outputVatMap": { "title": "VAT code by revenue rate" }, + "categoryMap": { "title": "Expense category → account" }, + "accounts": { "title": "Chart of accounts" }, + "vatCodes": { "title": "VAT codes" }, + "account": { + "addTitle": "Add account", "editTitle": "Edit account", + "number": "Account number", "name": "Name", "type": "Type", + "confirmDelete": "Delete this account?" + }, + "vat": { + "addTitle": "Add VAT code", "editTitle": "Edit VAT code", + "code": "Code", "name": "Name", "rate": "Rate %", "direction": "Direction", + "account": "VAT account", "noAccount": "— none —", "confirmDelete": "Delete this VAT code?" + }, + "export": { + "title": "Treuhänder export", + "intro": "Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s accounting software.", + "format": "Target tool", + "format_generic": "Generic (all columns)", + "format_banana": "Banana Accounting", + "format_bexio": "bexio", + "download": "Download CSV", + "failed": "Export failed.", + "disclaimer": "Accrual basis only (document dates) — payments/bank movements are not included. Account + VAT codes follow your Chart-of-accounts mapping. Always review the import with your Treuhänder before filing." + } + }, "taxReport": { "title": "Tax report", "intro": "Period-scoped revenue list with net + VAT breakdown grouped by VAT rate. Cancelled invoices stay visible for audit-trail continuity but are excluded from totals.", diff --git a/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx b/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx new file mode 100644 index 00000000..2cc82e6d --- /dev/null +++ b/frontend/src/pages/admin/accounting/ChartOfAccountsPage.tsx @@ -0,0 +1,323 @@ +/** + * Accounting → Chart of accounts (Layer A). + * + * Full CRUD for the Swiss/LI KMU-Kontenrahmen + MWST codes, plus the mappings + * the Treuhänder export relies on: which account each expense category books + * to, the default/system accounts, and the tax-treatment → VAT-code maps. + * + * This data drives the export only — picpeak is not a double-entry ledger. + */ +import React, { useEffect, useMemo, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react'; +import { Button, Card, CardContent, Input, Loading } from '../../../components/common'; +import { + ledgerService, type LedgerAccount, type VatCode, type AccountType, type VatDirection, type LedgerSettings, +} from '../../../services/ledger.service'; +import { categoryLabel } from '../../../services/accounting.service'; + +const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense']; +const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; +const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods']; +const OUTPUT_RATES = ['8.1', '2.6', '3.8', '0']; +const SETTING_ACCOUNT_KEYS: (keyof LedgerSettings)[] = [ + 'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash', + 'ledger_account_default_revenue', 'ledger_account_default_expense', + 'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue', +]; + +// ── account modal ────────────────────────────────────────────────────── +const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onDone: () => void }> = ({ account, onClose, onDone }) => { + const { t } = useTranslation(); + const isEdit = !!account; + const [number, setNumber] = useState(account?.number ?? ''); + const [name, setName] = useState(account?.name ?? ''); + const [type, setType] = useState(account?.type ?? 'expense'); + const save = useMutation({ + mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }), + onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + return ( +
+
+
+

{isEdit ? t('ledger.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}

+ +
+
+
setNumber(e.target.value)} placeholder="6700" />
+
setName(e.target.value)} />
+
+ +
+
+
+ + +
+
+
+ ); +}; + +// ── VAT code modal ───────────────────────────────────────────────────── +const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () => void; onDone: () => void }> = ({ vat, accounts, onClose, onDone }) => { + const { t } = useTranslation(); + const isEdit = !!vat; + const [code, setCode] = useState(vat?.code ?? ''); + const [name, setName] = useState(vat?.name ?? ''); + const [rate, setRate] = useState(vat ? String(vat.rate) : '8.1'); + const [direction, setDirection] = useState(vat?.direction ?? 'input'); + const [accountId, setAccountId] = useState(vat?.account_id ?? ''); + const save = useMutation({ + mutationFn: () => { + const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) }; + return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload); + }, + onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + return ( +
+
+
+

{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}

+ +
+
+
+
setCode(e.target.value)} placeholder="VST81" />
+
setRate(e.target.value)} inputMode="decimal" />
+
+
setName(e.target.value)} />
+
+ +
+
+ +
+
+
+ + +
+
+
+ ); +}; + +export const ChartOfAccountsPage: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [accountModal, setAccountModal] = useState<{ account?: LedgerAccount } | null>(null); + const [vatModal, setVatModal] = useState<{ vat?: VatCode } | null>(null); + + const { data: accounts, isLoading: la } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() }); + const { data: vatCodes, isLoading: lv } = useQuery({ queryKey: ['ledger-vat-codes'], queryFn: () => ledgerService.listVatCodes() }); + const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() }); + + // Local editable copy of the settings (default accounts + VAT maps). + const [settings, setSettings] = useState({}); + useEffect(() => { if (mappings?.settings) setSettings(mappings.settings); }, [mappings?.settings]); + + const accountOptions = useMemo(() => (accounts ?? []).filter((a) => a.active), [accounts]); + const inputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'input'), [vatCodes]); + const outputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'output'), [vatCodes]); + + const refetchAll = () => { qc.invalidateQueries({ queryKey: ['ledger-accounts'] }); qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }; + + const delAccount = useMutation({ + mutationFn: (id: number) => ledgerService.deleteAccount(id), + onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + const delVat = useMutation({ + mutationFn: (id: number) => ledgerService.deleteVatCode(id), + onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + const setCat = useMutation({ + mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + const saveSettings = useMutation({ + mutationFn: () => ledgerService.updateSettings(settings), + onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + + const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value })); + const setVatMap = (tt: string, code: string) => setSettings((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } })); + const setOutputVatMap = (rate: string, code: string) => setSettings((s) => ({ ...s, ledger_output_vat_map: { ...(s.ledger_output_vat_map || {}), [rate]: code } })); + + if (la || lv || lm) return ; + + return ( +
+

+ + {t('ledger.intro', 'Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänder’s setup.')} +

+ + {/* Default + system accounts */} + + +

{t('ledger.defaults.title', 'Default & system accounts')}

+
+ {SETTING_ACCOUNT_KEYS.map((key) => ( +
+ + +
+ ))} +
+ +

{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}

+
+ {TAX_TREATMENTS.map((tt) => ( +
+ + +
+ ))} +
+ +

{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}

+
+ {OUTPUT_RATES.map((rate) => ( +
+ + +
+ ))} +
+ +
+ +
+
+
+ + {/* Category → account */} + + +

{t('ledger.categoryMap.title', 'Expense category → account')}

+
+ {(mappings?.categories ?? []).map((c) => ( +
+ {categoryLabel(c as any, t)} + +
+ ))} +
+
+
+ + {/* Chart of accounts */} + + +
+

{t('ledger.accounts.title', 'Chart of accounts')}

+ +
+
+ + + + + + + + + + + {(accounts ?? []).map((a) => ( + + + + + + + ))} + +
{t('ledger.account.number', 'No.')}{t('ledger.account.name', 'Name')}{t('ledger.account.type', 'Type')}{t('common.actions', 'Actions')}
{a.number}{a.name}{t(`ledger.accountType.${a.type}`, a.type)} +
+ + +
+
+
+
+
+ + {/* VAT codes */} + + +
+

{t('ledger.vatCodes.title', 'VAT codes')}

+ +
+
+ + + + + + + + + + + + {(vatCodes ?? []).map((v) => ( + + + + + + + + ))} + +
{t('ledger.vat.code', 'Code')}{t('ledger.vat.name', 'Name')}{t('ledger.vat.rate', 'Rate %')}{t('ledger.vat.direction', 'Direction')}{t('common.actions', 'Actions')}
{v.code}{v.name}{Number(v.rate).toFixed(1)}{t(`ledger.vatDirection.${v.direction}`, v.direction)} +
+ + +
+
+
+
+
+ + {accountModal && setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />} + {vatModal && setVatModal(null)} onDone={() => { setVatModal(null); refetchAll(); }} />} +
+ ); +}; + +export default ChartOfAccountsPage; diff --git a/frontend/src/pages/admin/accounting/LedgerExportPage.tsx b/frontend/src/pages/admin/accounting/LedgerExportPage.tsx new file mode 100644 index 00000000..737bff3a --- /dev/null +++ b/frontend/src/pages/admin/accounting/LedgerExportPage.tsx @@ -0,0 +1,126 @@ +/** + * Accounting → Treuhänder export (Layer A). + * + * Picks a period + currency + target tool, then downloads the collective + * journal (accrual Buchungssätze) as CSV for import into the Treuhänder's + * double-entry software. Output is a guideline — disclaimer on the page. + */ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { FileSpreadsheet, Download, AlertCircle } from 'lucide-react'; +import { Button, Card, CardContent, LocalizedDateInput } from '../../../components/common'; +import { ledgerService, type ExportFormat } from '../../../services/ledger.service'; + +const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500'; +const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1'; + +type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom'; +const FORMATS: ExportFormat[] = ['generic', 'banana', 'bexio']; + +function periodForPreset(preset: PeriodPreset, today = new Date()): { from: string; to: string } { + const y = today.getFullYear(); + const pad = (n: number) => String(n).padStart(2, '0'); + if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` }; + if (preset === 'lastYear') return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31` }; + const quarter = Math.floor(today.getMonth() / 3); + if (preset === 'thisQuarter') { + const sm = quarter * 3; const em = sm + 2; + return { from: `${y}-${pad(sm + 1)}-01`, to: `${y}-${pad(em + 1)}-${pad(new Date(y, em + 1, 0).getDate())}` }; + } + let qy = y; let q = quarter - 1; if (q < 0) { q = 3; qy = y - 1; } + const sm = q * 3; const em = sm + 2; + return { from: `${qy}-${pad(sm + 1)}-01`, to: `${qy}-${pad(em + 1)}-${pad(new Date(qy, em + 1, 0).getDate())}` }; +} + +function triggerDownload(url: string, filename: string) { + const a = document.createElement('a'); + a.href = url; a.download = filename; a.style.display = 'none'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export const LedgerExportPage: React.FC = () => { + const { t } = useTranslation(); + const [preset, setPreset] = useState('thisYear'); + const initial = useMemo(() => periodForPreset('thisYear'), []); + const [from, setFrom] = useState(initial.from); + const [to, setTo] = useState(initial.to); + const [currency, setCurrency] = useState('CHF'); + const [format, setFormat] = useState('generic'); + const [busy, setBusy] = useState(false); + + const onPreset = (next: PeriodPreset) => { + setPreset(next); + if (next !== 'custom') { const p = periodForPreset(next); setFrom(p.from); setTo(p.to); } + }; + + const handleExport = async () => { + setBusy(true); + try { + const { url, filename } = await ledgerService.downloadExportUrl({ from, to, currency, format }); + triggerDownload(url, filename); + } catch (e: any) { + toast.error(e?.response?.data?.error || e.message || t('ledger.export.failed', 'Export failed.')); + } finally { setBusy(false); } + }; + + return ( + + +
+
+ +
+
+

{t('ledger.export.title', 'Treuhänder export')}

+

{t('ledger.export.intro', 'Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänder’s accounting software.')}

+
+
+ +
+
+ + +
+
+
{ setFrom(iso); setPreset('custom'); }} />
+
{ setTo(iso); setPreset('custom'); }} />
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +

+ + {t('ledger.export.disclaimer', 'Accrual basis only (document dates) — payments/bank movements are not included. Account + VAT codes follow your Chart-of-accounts mapping. Always review the import with your Treuhänder before filing.')} +

+
+
+ ); +}; + +export default LedgerExportPage; diff --git a/frontend/src/services/ledger.service.ts b/frontend/src/services/ledger.service.ts new file mode 100644 index 00000000..c325af14 --- /dev/null +++ b/frontend/src/services/ledger.service.ts @@ -0,0 +1,98 @@ +/** + * Admin → Ledger (Accounting Layer A) API client. Hits /api/admin/ledger/*. + * + * Chart of accounts + VAT codes CRUD, category/settings mappings, and the + * Treuhänder collective-journal export (generic / Banana / bexio). + */ +import { api } from '../config/api'; + +export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'; +export type VatDirection = 'output' | 'input'; +export type ExportFormat = 'generic' | 'banana' | 'bexio'; + +export interface LedgerAccount { + id: number; + number: string; + name: string; + type: AccountType; + is_seed: boolean; + active: boolean; + display_order: number; +} + +export interface VatCode { + id: number; + code: string; + name: string; + rate: number; + direction: VatDirection; + account_id: number | null; + is_seed: boolean; + active: boolean; + display_order: number; +} + +export interface LedgerCategory { + id: number; + name: string; + color: string | null; + ledger_account_id: number | null; +} + +export interface LedgerSettings { + ledger_account_debitoren?: string; + ledger_account_kreditoren?: string; + ledger_account_bank?: string; + ledger_account_cash?: string; + ledger_account_default_revenue?: string; + ledger_account_default_expense?: string; + ledger_account_mileage?: string; + ledger_account_per_diem?: string; + ledger_account_rebilled_revenue?: string; + ledger_vat_map?: Record; + ledger_output_vat_map?: Record; +} + +export interface LedgerMappings { categories: LedgerCategory[]; settings: LedgerSettings; } + +export interface ExportParams { from: string; to: string; currency: string; format: ExportFormat; } + +export const ledgerService = { + // ── accounts ── + async listAccounts(): Promise { const { data } = await api.get('/admin/ledger/accounts'); return data.items; }, + async createAccount(payload: { number: string; name: string; type: AccountType }): Promise { + const { data } = await api.post('/admin/ledger/accounts', payload); return data.account; + }, + async updateAccount(id: number, payload: Partial<{ number: string; name: string; type: AccountType; active: boolean }>): Promise { + const { data } = await api.patch(`/admin/ledger/accounts/${id}`, payload); return data.account; + }, + async deleteAccount(id: number): Promise { await api.delete(`/admin/ledger/accounts/${id}`); }, + + // ── VAT codes ── + async listVatCodes(): Promise { const { data } = await api.get('/admin/ledger/vat-codes'); return data.items; }, + async createVatCode(payload: { code: string; name: string; rate: number; direction: VatDirection; accountId?: number | null }): Promise { + const { data } = await api.post('/admin/ledger/vat-codes', payload); return data.vatCode; + }, + async updateVatCode(id: number, payload: Partial<{ code: string; name: string; rate: number; direction: VatDirection; accountId: number | null; active: boolean }>): Promise { + const { data } = await api.patch(`/admin/ledger/vat-codes/${id}`, payload); return data.vatCode; + }, + async deleteVatCode(id: number): Promise { await api.delete(`/admin/ledger/vat-codes/${id}`); }, + + // ── mappings ── + async getMappings(): Promise { const { data } = await api.get('/admin/ledger/mappings'); return data; }, + async setCategoryAccount(id: number, ledgerAccountId: number | null): Promise { + const { data } = await api.patch(`/admin/ledger/mappings/category/${id}`, { ledgerAccountId }); return data.category; + }, + async updateSettings(patch: Partial): Promise<{ updated: string[] }> { + const { data } = await api.patch('/admin/ledger/mappings/settings', patch); return data; + }, + + // ── export ── + async downloadExportUrl(params: ExportParams): Promise<{ url: string; filename: string }> { + const usp = new URLSearchParams({ from: params.from, to: params.to, currency: params.currency, format: params.format }); + const res = await api.get(`/admin/ledger/export?${usp.toString()}`, { responseType: 'blob' }); + const url = URL.createObjectURL(res.data); + const filename = `journal_${params.from}_to_${params.to}_${params.currency}_${params.format}.csv`; + return { url, filename }; + }, +};