feat(accounting): Layer A frontend — chart of accounts CRUD + Treuhänder export UI

- ledger.service.ts: accounts/VAT-codes/mappings CRUD + export client
- ChartOfAccountsPage: full CRUD for the Swiss/LI KMU chart + MWST codes,
  category→account mapping, default/system accounts + tax-treatment/rate→VAT
  maps, with a 'guideline only' note
- LedgerExportPage: period + currency + target tool (generic/Banana/bexio)
  → collective-journal CSV download, with accrual-only + Treuhänder disclaimer
- AccountingLayout: 'Treuhänder export' (taxReport flag) + 'Chart of accounts'
  (accounting flag) sub-nav entries; App routes wired
- en/de translations (accounting.taxTreatment.* enum + ledger.* namespace)

de/en authored natively; no machine-translated locales touched here.
This commit is contained in:
Luca
2026-06-12 00:48:39 +02:00
parent 03cc250b47
commit 7e0098edcd
7 changed files with 687 additions and 4 deletions
+6
View File
@@ -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() {
</Route>
<Route element={<RequireFeature flag="taxReport" />}>
<Route path="tax-report" element={<TaxReportPage />} />
<Route path="export" element={<LedgerExportPage />} />
</Route>
{/* Chart of accounts + VAT codes (Layer A) — gated by
the accounting master flag alongside the section. */}
<Route path="ledger" element={<ChartOfAccountsPage />} />
<Route index element={<AccountingIndex />} />
</Route>
</Route>
@@ -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]);
+59 -1
View File
@@ -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.",
+59 -1
View File
@@ -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änders 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änders 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.",
@@ -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<AccountType>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div><label className={labelCls}>{t('ledger.account.number', 'Account number')}</label><Input value={number} onChange={(e) => setNumber(e.target.value)} placeholder="6700" /></div>
<div><label className={labelCls}>{t('ledger.account.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.account.type', 'Type')}</label>
<select value={type} onChange={(e) => setType(e.target.value as AccountType)} className={selectCls}>
{ACCOUNT_TYPES.map((tp) => <option key={tp} value={tp}>{t(`ledger.accountType.${tp}`, tp)}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !number || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
// ── 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<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{t('ledger.vat.code', 'Code')}</label><Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="VST81" /></div>
<div><label className={labelCls}>{t('ledger.vat.rate', 'Rate %')}</label><Input value={rate} onChange={(e) => setRate(e.target.value)} inputMode="decimal" /></div>
</div>
<div><label className={labelCls}>{t('ledger.vat.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.vat.direction', 'Direction')}</label>
<select value={direction} onChange={(e) => setDirection(e.target.value as VatDirection)} className={selectCls}>
<option value="input">{t('ledger.vatDirection.input', 'Input (Vorsteuer)')}</option>
<option value="output">{t('ledger.vatDirection.output', 'Output (Umsatzsteuer)')}</option>
</select>
</div>
<div><label className={labelCls}>{t('ledger.vat.account', 'VAT account')}</label>
<select value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')} className={selectCls}>
<option value="">{t('ledger.vat.noAccount', '— none —')}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !code || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
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<LedgerSettings>({});
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 <Loading />;
return (
<div className="space-y-6">
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{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änders setup.')}</span>
</p>
{/* Default + system accounts */}
<Card>
<CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.defaults.title', 'Default & system accounts')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{SETTING_ACCOUNT_KEYS.map((key) => (
<div key={key}>
<label className={labelCls}>{t(`ledger.defaults.${key}`, key)}</label>
<select value={settings[key] as string ?? ''} onChange={(e) => setAcctSetting(key, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{accountOptions.map((a) => <option key={a.id} value={a.number}>{a.number} · {a.name}</option>)}
</select>
</div>
))}
</div>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TAX_TREATMENTS.map((tt) => (
<div key={tt}>
<label className={labelCls}>{t(`accounting.taxTreatment.${tt}`, tt)}</label>
<select value={settings.ledger_vat_map?.[tt] ?? ''} onChange={(e) => setVatMap(tt, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{inputVat.map((v) => <option key={v.id} value={v.code}>{v.code} · {v.name}</option>)}
</select>
</div>
))}
</div>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{OUTPUT_RATES.map((rate) => (
<div key={rate}>
<label className={labelCls}>{rate}%</label>
<select value={settings.ledger_output_vat_map?.[rate] ?? ''} onChange={(e) => setOutputVatMap(rate, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{outputVat.map((v) => <option key={v.id} value={v.code}>{v.code}</option>)}
</select>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>{saveSettings.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
</div>
</CardContent>
</Card>
{/* Category → account */}
<Card>
<CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.categoryMap.title', 'Expense category → account')}</h2>
<div className="space-y-2">
{(mappings?.categories ?? []).map((c) => (
<div key={c.id} className="flex items-center gap-3">
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">{categoryLabel(c as any, t)}</span>
<select value={c.ledger_account_id ?? ''} onChange={(e) => setCat.mutate({ id: c.id, accId: e.target.value ? Number(e.target.value) : null })} className={selectCls} style={{ maxWidth: 320 }}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{accountOptions.filter((a) => a.type === 'expense').map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
))}
</div>
</CardContent>
</Card>
{/* Chart of accounts */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.accounts.title', 'Chart of accounts')}</h2>
<Button size="sm" onClick={() => setAccountModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.account.addTitle', 'Add account')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.number', 'No.')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.type', 'Type')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(accounts ?? []).map((a) => (
<tr key={a.id} className={a.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{a.number}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{a.name}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.accountType.${a.type}`, a.type)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setAccountModal({ account: a })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.account.confirmDelete', 'Delete this account?') as string)) delAccount.mutate(a.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* VAT codes */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.vatCodes.title', 'VAT codes')}</h2>
<Button size="sm" onClick={() => setVatModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.vat.addTitle', 'Add VAT code')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.code', 'Code')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('ledger.vat.rate', 'Rate %')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.direction', 'Direction')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(vatCodes ?? []).map((v) => (
<tr key={v.id} className={v.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 font-medium text-neutral-900 dark:text-neutral-100">{v.code}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{v.name}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-neutral-700 dark:text-neutral-300">{Number(v.rate).toFixed(1)}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.vatDirection.${v.direction}`, v.direction)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setVatModal({ vat: v })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.vat.confirmDelete', 'Delete this VAT code?') as string)) delVat.mutate(v.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{accountModal && <AccountModal account={accountModal.account} onClose={() => setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />}
{vatModal && <VatModal vat={vatModal.vat} accounts={accounts ?? []} onClose={() => setVatModal(null)} onDone={() => { setVatModal(null); refetchAll(); }} />}
</div>
);
};
export default ChartOfAccountsPage;
@@ -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<PeriodPreset>('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<ExportFormat>('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 (
<Card padding="md">
<CardContent className="p-0">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center flex-shrink-0">
<FileSpreadsheet className="w-5 h-5" />
</div>
<div>
<h1 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.export.title', 'Treuhänder export')}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">{t('ledger.export.intro', 'Download the collective journal (revenue + costs as accrual postings with account and VAT codes) for import into your Treuhänders accounting software.')}</p>
</div>
</div>
<div className="space-y-3 max-w-md">
<div>
<label className={labelCls}>{t('taxReport.filters.period', 'Period')}</label>
<select value={preset} onChange={(e) => onPreset(e.target.value as PeriodPreset)} className={selectCls}>
<option value="thisYear">{t('taxReport.filters.thisYear', 'This year')}</option>
<option value="lastYear">{t('taxReport.filters.lastYear', 'Last year')}</option>
<option value="thisQuarter">{t('taxReport.filters.thisQuarter', 'This quarter')}</option>
<option value="lastQuarter">{t('taxReport.filters.lastQuarter', 'Last quarter')}</option>
<option value="custom">{t('taxReport.filters.custom', 'Custom range')}</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{t('taxReport.filters.from', 'From')}</label><LocalizedDateInput value={from} onChange={(iso) => { setFrom(iso); setPreset('custom'); }} /></div>
<div><label className={labelCls}>{t('taxReport.filters.to', 'To')}</label><LocalizedDateInput value={to} onChange={(iso) => { setTo(iso); setPreset('custom'); }} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>{t('taxReport.filters.currency', 'Currency')}</label>
<select value={currency} onChange={(e) => setCurrency(e.target.value)} className={selectCls}>
<option value="CHF">CHF</option><option value="EUR">EUR</option><option value="USD">USD</option><option value="GBP">GBP</option>
</select>
</div>
<div>
<label className={labelCls}>{t('ledger.export.format', 'Target tool')}</label>
<select value={format} onChange={(e) => setFormat(e.target.value as ExportFormat)} className={selectCls}>
{FORMATS.map((f) => <option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option>)}
</select>
</div>
</div>
<div className="flex justify-end pt-1">
<Button onClick={handleExport} disabled={busy || !from || !to} isLoading={busy} leftIcon={<Download className="w-4 h-4" />}>
{t('ledger.export.download', 'Download CSV')}
</Button>
</div>
</div>
<p className="flex items-start gap-2 mt-5 text-xs text-neutral-500 dark:text-neutral-400">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{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.')}</span>
</p>
</CardContent>
</Card>
);
};
export default LedgerExportPage;
+98
View File
@@ -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<string, string>;
ledger_output_vat_map?: Record<string, string>;
}
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<LedgerAccount[]> { const { data } = await api.get('/admin/ledger/accounts'); return data.items; },
async createAccount(payload: { number: string; name: string; type: AccountType }): Promise<LedgerAccount> {
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<LedgerAccount> {
const { data } = await api.patch(`/admin/ledger/accounts/${id}`, payload); return data.account;
},
async deleteAccount(id: number): Promise<void> { await api.delete(`/admin/ledger/accounts/${id}`); },
// ── VAT codes ──
async listVatCodes(): Promise<VatCode[]> { 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<VatCode> {
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<VatCode> {
const { data } = await api.patch(`/admin/ledger/vat-codes/${id}`, payload); return data.vatCode;
},
async deleteVatCode(id: number): Promise<void> { await api.delete(`/admin/ledger/vat-codes/${id}`); },
// ── mappings ──
async getMappings(): Promise<LedgerMappings> { const { data } = await api.get('/admin/ledger/mappings'); return data; },
async setCategoryAccount(id: number, ledgerAccountId: number | null): Promise<LedgerCategory> {
const { data } = await api.patch(`/admin/ledger/mappings/category/${id}`, { ledgerAccountId }); return data.category;
},
async updateSettings(patch: Partial<LedgerSettings>): 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 };
},
};