feat(accounting): move Treuhänder export onto the Tax page

The standalone 'Treuhänder export' tab duplicated the Tax page's period/
currency filters over the same data. Fold the collective-journal export into
the Tax page as a third export action (target-tool format picker: generic /
Banana / bexio), beside Export CSV/PDF, with a link to its Chart-of-accounts
config. Removes the Accounting sub-nav 'export' tab (old /export route now
redirects to the Tax page); keeps Chart of accounts as its own setup tab.
Deletes the now-orphaned LedgerExportPage.

Build + JSON parse green.
This commit is contained in:
Luca
2026-06-15 18:57:43 +02:00
parent f3e77e7807
commit b1f73c1df9
6 changed files with 89 additions and 157 deletions
+3 -2
View File
@@ -70,7 +70,6 @@ import { AccountingLayout, AccountingIndex } from './components/admin/Accounting
import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage'; import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage';
import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage'; import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage';
import { ChartOfAccountsPage } from './pages/admin/accounting/ChartOfAccountsPage'; import { ChartOfAccountsPage } from './pages/admin/accounting/ChartOfAccountsPage';
import { LedgerExportPage } from './pages/admin/accounting/LedgerExportPage';
import { RequireFeature } from './components/admin/RequireFeature'; import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -288,7 +287,9 @@ function App() {
</Route> </Route>
<Route element={<RequireFeature flag="taxReport" />}> <Route element={<RequireFeature flag="taxReport" />}>
<Route path="tax-report" element={<TaxReportPage />} /> <Route path="tax-report" element={<TaxReportPage />} />
<Route path="export" element={<LedgerExportPage />} /> {/* Treuhänder export moved onto the Tax page; keep
the old path working for bookmarks. */}
<Route path="export" element={<Navigate to="/admin/accounting/tax-report" replace />} />
</Route> </Route>
{/* Chart of accounts + VAT codes (Layer A) — gated by {/* Chart of accounts + VAT codes (Layer A) — gated by
the accounting master flag alongside the section. */} the accounting master flag alongside the section. */}
@@ -9,7 +9,7 @@
import React from 'react'; import React from 'react';
import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Landmark, Calculator, Inbox, Wallet, BookOpen, FileSpreadsheet } from 'lucide-react'; import { Landmark, Calculator, Inbox, Wallet, BookOpen } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -45,18 +45,13 @@ export const AccountingLayout: React.FC = () => {
}, },
{ {
key: 'tax-report', key: 'tax-report',
// The Treuhänder export now lives ON the Tax page (same period/currency
// filters, same data) instead of a separate sub-tab — see TaxReportPage.
to: '/admin/accounting/tax-report', to: '/admin/accounting/tax-report',
label: t('accounting.subnav.taxReport', 'Tax'), label: t('accounting.subnav.taxReport', 'Tax'),
icon: Calculator, icon: Calculator,
featureFlag: 'taxReport', featureFlag: 'taxReport',
}, },
{
key: 'export',
to: '/admin/accounting/export',
label: t('accounting.subnav.export', 'Treuhänder export'),
icon: FileSpreadsheet,
featureFlag: 'taxReport',
},
{ {
key: 'ledger', key: 'ledger',
to: '/admin/accounting/ledger', to: '/admin/accounting/ledger',
+3
View File
@@ -3937,6 +3937,9 @@
"currency": "Währung" "currency": "Währung"
}, },
"exportPdf": "PDF exportieren", "exportPdf": "PDF exportieren",
"ledgerExport": "Treuhänder-Export",
"ledgerExportHint": "Doppelte Buchung für Ihren Treuhänder, abgebildet über Ihren Kontenplan.",
"ledgerExportConfigure": "Einrichten →",
"exportCsv": "CSV exportieren", "exportCsv": "CSV exportieren",
"exportFailed": "Export fehlgeschlagen. Bitte erneut versuchen.", "exportFailed": "Export fehlgeschlagen. Bitte erneut versuchen.",
"errorTitle": "Steuerliste konnte nicht geladen werden", "errorTitle": "Steuerliste konnte nicht geladen werden",
+3
View File
@@ -3937,6 +3937,9 @@
"currency": "Currency" "currency": "Currency"
}, },
"exportPdf": "Export PDF", "exportPdf": "Export PDF",
"ledgerExport": "Treuhänder export",
"ledgerExportHint": "Double-entry journal for your accountant, mapped via your Chart of accounts.",
"ledgerExportConfigure": "Configure →",
"exportCsv": "Export CSV", "exportCsv": "Export CSV",
"exportFailed": "Export failed. Please try again.", "exportFailed": "Export failed. Please try again.",
"errorTitle": "Could not load tax report", "errorTitle": "Could not load tax report",
@@ -1,126 +0,0 @@
/**
* 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;
@@ -17,7 +17,8 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react'; import { Calculator, Download, FileDown, FileSpreadsheet, AlertCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common'; import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common';
// Lightweight native select styled to match Input — the common barrel // Lightweight native select styled to match Input — the common barrel
@@ -26,9 +27,12 @@ import { Button, Card, Loading, LocalizedDateInput } from '../../../components/c
const selectClassName = const selectClassName =
'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'; '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';
import { taxReportService, type TaxReportParams } from '../../../services/taxReport.service'; import { taxReportService, type TaxReportParams } from '../../../services/taxReport.service';
import { ledgerService, type ExportFormat } from '../../../services/ledger.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'bexio'];
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom'; type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
function isoDate(d: Date): string { function isoDate(d: Date): string {
@@ -95,7 +99,10 @@ export const TaxReportPage: React.FC = () => {
const [from, setFrom] = useState(initialPeriod.from); const [from, setFrom] = useState(initialPeriod.from);
const [to, setTo] = useState(initialPeriod.to); const [to, setTo] = useState(initialPeriod.to);
const [currency, setCurrency] = useState<string>('CHF'); const [currency, setCurrency] = useState<string>('CHF');
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | null>(null); const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | 'ledger' | null>(null);
// Treuhänder (collective-journal) export — same period/currency as the
// report; target tool picks the import format (generic / Banana / bexio).
const [ledgerFormat, setLedgerFormat] = useState<ExportFormat>('generic');
// Unified-ledger sort (#5). Defaults to date ascending — matches the // Unified-ledger sort (#5). Defaults to date ascending — matches the
// server-side order so the first paint is stable. // server-side order so the first paint is stable.
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'date', dir: 'asc' }); const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'date', dir: 'asc' });
@@ -136,6 +143,22 @@ export const TaxReportPage: React.FC = () => {
} }
}; };
// Treuhänder collective-journal export (double-entry postings with account +
// VAT codes) — reuses the page's period/currency, adds the target-tool format.
const handleLedgerExport = async () => {
setIsExporting('ledger');
try {
const { url, filename } = await ledgerService.downloadExportUrl({ from, to, currency, format: ledgerFormat });
triggerBrowserDownload(url, filename);
} catch (err: any) {
toast.error(err?.response?.data?.error || t('taxReport.exportFailed', 'Export failed. Please try again.'));
// eslint-disable-next-line no-console
console.error(err);
} finally {
setIsExporting(null);
}
};
// Per maintainer: every CH/LI/DE/AT-based business writes 1'000.00 // Per maintainer: every CH/LI/DE/AT-based business writes 1'000.00
// regardless of document language, so we default to de-CH (the only // regardless of document language, so we default to de-CH (the only
// Intl locale producing apostrophe thousands). Non-DACH operators // Intl locale producing apostrophe thousands). Non-DACH operators
@@ -271,7 +294,8 @@ export const TaxReportPage: React.FC = () => {
</select> </select>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-1"> <div className="space-y-2 pt-1">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => handleExport('csv')} onClick={() => handleExport('csv')}
@@ -291,6 +315,38 @@ export const TaxReportPage: React.FC = () => {
{t('taxReport.exportPdf', 'Export PDF')} {t('taxReport.exportPdf', 'Export PDF')}
</Button> </Button>
</div> </div>
{/* Treuhänder collective-journal export — moved here from its own
tab; reuses the same period/currency. Format = target tool. */}
<div className="flex flex-wrap items-center justify-end gap-2">
<select
value={ledgerFormat}
onChange={(e) => setLedgerFormat(e.target.value as ExportFormat)}
disabled={exportsDisabled}
aria-label={t('ledger.export.format', 'Target tool') as string}
className={`${selectClassName} w-auto`}
>
{LEDGER_FORMATS.map((f) => (
<option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option>
))}
</select>
<Button
variant="outline"
onClick={handleLedgerExport}
disabled={exportsDisabled}
isLoading={isExporting === 'ledger'}
leftIcon={<FileSpreadsheet className="w-4 h-4" />}
>
{t('taxReport.ledgerExport', 'Treuhänder export')}
</Button>
</div>
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 text-right">
{t('taxReport.ledgerExportHint', 'Double-entry journal for your accountant, mapped via your Chart of accounts.')}{' '}
<Link to="/admin/accounting/ledger" className="underline hover:text-neutral-600 dark:hover:text-neutral-300">
{t('taxReport.ledgerExportConfigure', 'Configure →')}
</Link>
</p>
</div>
</div> </div>
</Card> </Card>