feat(accounting): clearer tax-export window + gate journal export on accounting flag

- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
  for you) and 'Accounting journal' (for your accountant), each with a
  one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
  export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
  Chart-of-accounts mapping), so gate it on the 'accounting' master — the
  group only renders when accounting is on, and the backend /export route no
  longer requires the 'taxReport' sub-flag (the router already requires accounting).

Build + node --check + JSON parse green.
This commit is contained in:
Luca
2026-06-15 19:18:36 +02:00
parent b1f73c1df9
commit 3edd832103
4 changed files with 96 additions and 58 deletions
+7 -5
View File
@@ -6,9 +6,9 @@
* /mappings GET/PATCH category→account + default-account/VAT settings * /mappings GET/PATCH category→account + default-account/VAT settings
* /export GET Treuhänder collective-journal CSV (generic|banana|bexio) * /export GET Treuhänder collective-journal CSV (generic|banana|bexio)
* *
* Gated by the `accounting` master flag; export additionally requires the * Gated by the `accounting` master flag (all routes, incl. /export). Uses the
* `taxReport` sub-flag (it's the export umbrella). Uses the `accounting.*` * `accounting.*` permissions. Output is a GUIDELINE — the UI carries the
* permissions. Output is a GUIDELINE — the UI carries the Treuhänder caveat. * Treuhänder caveat.
*/ */
const express = require('express'); const express = require('express');
const { body, param, query } = require('express-validator'); const { body, param, query } = require('express-validator');
@@ -32,7 +32,6 @@ function requireFlag(key, code) {
}; };
} }
const requireAccounting = requireFlag('accounting', 'ACCOUNTING_DISABLED'); const requireAccounting = requireFlag('accounting', 'ACCOUNTING_DISABLED');
const requireTaxReport = requireFlag('taxReport', 'TAX_REPORT_DISABLED');
router.use(adminAuth); router.use(adminAuth);
router.use(requireAccounting); router.use(requireAccounting);
@@ -107,7 +106,10 @@ router.patch('/mappings/settings', requirePermission('accounting.manage'), handl
})); }));
// ── Treuhänder export ──────────────────────────────────────────────── // ── Treuhänder export ────────────────────────────────────────────────
router.get('/export', requireTaxReport, requirePermission('bills.view'), // Gated by the router-level `accounting` flag only — the export lives on the
// Tax page now but is an accounting-layer feature (needs the chart-of-accounts
// mapping), so it no longer requires the `taxReport` sub-flag.
router.get('/export', requirePermission('bills.view'),
[query('from').matches(/^\d{4}-\d{2}-\d{2}$/), query('to').matches(/^\d{4}-\d{2}-\d{2}$/), [query('from').matches(/^\d{4}-\d{2}-\d{2}$/), query('to').matches(/^\d{4}-\d{2}-\d{2}$/),
query('currency').matches(/^[A-Za-z]{3}$/), query('format').optional().isIn(ledgerService.EXPORT_FORMATS)], query('currency').matches(/^[A-Za-z]{3}$/), query('format').optional().isIn(ledgerService.EXPORT_FORMATS)],
handleAsync(async (req, res) => { handleAsync(async (req, res) => {
+6 -1
View File
@@ -3938,8 +3938,13 @@
}, },
"exportPdf": "PDF exportieren", "exportPdf": "PDF exportieren",
"ledgerExport": "Treuhänder-Export", "ledgerExport": "Treuhänder-Export",
"ledgerExportHint": "Doppelte Buchung für Ihren Treuhänder, abgebildet über Ihren Kontenplan.", "ledgerExportHint": "Doppelte Buchungssätze für Ihren Treuhänder, abgebildet über Ihren Kontenplan.",
"ledgerExportConfigure": "Einrichten →", "ledgerExportConfigure": "Einrichten →",
"export": {
"reportTitle": "Bericht",
"reportHint": "Lesbare Liste — für Ihre Unterlagen.",
"journalTitle": "Buchungsjournal"
},
"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",
+7 -2
View File
@@ -3937,9 +3937,14 @@
"currency": "Currency" "currency": "Currency"
}, },
"exportPdf": "Export PDF", "exportPdf": "Export PDF",
"ledgerExport": "Treuhänder export", "ledgerExport": "Accountant export",
"ledgerExportHint": "Double-entry journal for your accountant, mapped via your Chart of accounts.", "ledgerExportHint": "Double-entry postings for your accountant, mapped via your Chart of accounts.",
"ledgerExportConfigure": "Configure →", "ledgerExportConfigure": "Configure →",
"export": {
"reportTitle": "Report",
"reportHint": "Readable list — for your own records.",
"journalTitle": "Accounting journal"
},
"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",
@@ -28,6 +28,7 @@ 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 { ledgerService, type ExportFormat } from '../../../services/ledger.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -93,6 +94,7 @@ function triggerBrowserDownload(url: string, filename: string) {
export const TaxReportPage: React.FC = () => { export const TaxReportPage: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { flags } = useFeatureFlags();
const { format: fmtDate } = useLocalizedDate(); const { format: fmtDate } = useLocalizedDate();
const [preset, setPreset] = useState<PeriodPreset>('thisYear'); const [preset, setPreset] = useState<PeriodPreset>('thisYear');
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []); const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
@@ -294,58 +296,82 @@ export const TaxReportPage: React.FC = () => {
</select> </select>
</div> </div>
<div className="space-y-2 pt-1"> {/* Export area two clearly-separated groups so it's obvious
<div className="flex flex-wrap items-center justify-end gap-2"> what each file is and who it's for: the human-readable Report
<Button (PDF/CSV) and the accounting Journal (for the Treuhänder). The
variant="outline" Journal group only shows when the accounting layer is on, since
onClick={() => handleExport('csv')} it needs the Chart-of-accounts mapping. */}
disabled={exportsDisabled} <div className="pt-3 mt-1 border-t border-neutral-200 dark:border-neutral-700 space-y-3">
isLoading={isExporting === 'csv'} {/* Group 1 — Report (for you) */}
leftIcon={<FileDown className="w-4 h-4" />} <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
> <div className="min-w-0">
{t('taxReport.exportCsv', 'Export CSV')} <div className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
</Button> {t('taxReport.export.reportTitle', 'Report')}
<Button </div>
variant="primary" <div className="text-[11px] text-neutral-400 dark:text-neutral-500">
onClick={() => handleExport('pdf')} {t('taxReport.export.reportHint', 'Readable list — for your own records.')}
disabled={exportsDisabled} </div>
isLoading={isExporting === 'pdf'} </div>
leftIcon={<Download className="w-4 h-4" />} <div className="flex flex-wrap items-center gap-2">
> <Button
{t('taxReport.exportPdf', 'Export PDF')} variant="outline"
</Button> onClick={() => handleExport('csv')}
disabled={exportsDisabled}
isLoading={isExporting === 'csv'}
leftIcon={<FileDown className="w-4 h-4" />}
>
{t('taxReport.exportCsv', 'Export CSV')}
</Button>
<Button
variant="primary"
onClick={() => handleExport('pdf')}
disabled={exportsDisabled}
isLoading={isExporting === 'pdf'}
leftIcon={<Download className="w-4 h-4" />}
>
{t('taxReport.exportPdf', 'Export PDF')}
</Button>
</div>
</div> </div>
{/* Treuhänder collective-journal export moved here from its own {/* Group 2 — Accounting journal (for your accountant) */}
tab; reuses the same period/currency. Format = target tool. */} {flags.accounting && (
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-3 border-t border-dashed border-neutral-200 dark:border-neutral-800">
<select <div className="min-w-0">
value={ledgerFormat} <div className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
onChange={(e) => setLedgerFormat(e.target.value as ExportFormat)} {t('taxReport.export.journalTitle', 'Accounting journal')}
disabled={exportsDisabled} </div>
aria-label={t('ledger.export.format', 'Target tool') as string} <div className="text-[11px] text-neutral-400 dark:text-neutral-500">
className={`${selectClassName} w-auto`} {t('taxReport.ledgerExportHint', 'Double-entry postings 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">
{LEDGER_FORMATS.map((f) => ( {t('taxReport.ledgerExportConfigure', 'Configure →')}
<option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option> </Link>
))} </div>
</select> </div>
<Button <div className="flex flex-wrap items-center gap-2">
variant="outline" <select
onClick={handleLedgerExport} value={ledgerFormat}
disabled={exportsDisabled} onChange={(e) => setLedgerFormat(e.target.value as ExportFormat)}
isLoading={isExporting === 'ledger'} disabled={exportsDisabled}
leftIcon={<FileSpreadsheet className="w-4 h-4" />} aria-label={t('ledger.export.format', 'Target tool') as string}
> className={`${selectClassName} w-auto`}
{t('taxReport.ledgerExport', 'Treuhänder export')} >
</Button> {LEDGER_FORMATS.map((f) => (
</div> <option key={f} value={f}>{t(`ledger.export.format_${f}`, f)}</option>
<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.')}{' '} </select>
<Link to="/admin/accounting/ledger" className="underline hover:text-neutral-600 dark:hover:text-neutral-300"> <Button
{t('taxReport.ledgerExportConfigure', 'Configure →')} variant="outline"
</Link> onClick={handleLedgerExport}
</p> disabled={exportsDisabled}
isLoading={isExporting === 'ledger'}
leftIcon={<FileSpreadsheet className="w-4 h-4" />}
>
{t('taxReport.ledgerExport', 'Accountant export')}
</Button>
</div>
</div>
)}
</div> </div>
</div> </div>
</Card> </Card>