feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung

- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export
- Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer
- hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:32:55 +02:00
parent 0edd6e9b1a
commit 1f85277a3b
38 changed files with 2460 additions and 2 deletions
@@ -0,0 +1,226 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockPreview = vi.fn();
const mockExport = vi.fn();
vi.mock('@/lib/kantine-datev-api', () => ({
getKantineSettings: (...a: unknown[]) => mockGetSettings(...a),
saveKantineSettings: (...a: unknown[]) => mockSaveSettings(...a),
previewKantineCsv: (...a: unknown[]) => mockPreview(...a),
exportKantineCsv: (...a: unknown[]) => mockExport(...a),
KantineRequestError: class extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
) {
super(message);
}
},
}));
const mockDownload = vi.fn();
vi.mock('@/lib/download-base64', () => ({
downloadBase64: (...a: unknown[]) => mockDownload(...a),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
function mockUser(role: 'SUPER_ADMIN' | 'ADMIN' | 'USER') {
mockAuthStore.mockImplementation((selector: (s: { user: { role: string } }) => unknown) =>
selector({ user: { role } }),
);
}
import KantineDatevPage from './page';
const CONFIGURED = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111', configured: true };
const EMPTY = { beraterNr: null, mandantNr: null, lohnart: null, configured: false };
const GOOD_PREVIEW = {
rowCount: 3,
abrechnungsMonat: '03/2026',
totalCents: 12345,
errors: [],
warnings: [],
canExport: true,
blockedReason: null,
};
function csvFile() {
return new File(['x'], 'kantine.csv', { type: 'text/csv' });
}
async function upload(file = csvFile()) {
const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
beforeEach(() => {
mockUser('USER');
mockGetSettings.mockResolvedValue(CONFIGURED);
});
afterEach(() => {
cleanup();
for (const m of [
mockGetSettings,
mockSaveSettings,
mockPreview,
mockExport,
mockDownload,
mockAuthStore,
]) {
m.mockReset();
}
});
describe('KantineDatevPage — nicht eingerichtet', () => {
it('Administrator sieht Hinweis mit Weg zu den Einstellungen und das Formular', async () => {
mockUser('ADMIN');
mockGetSettings.mockResolvedValue(EMPTY);
render(<KantineDatevPage />);
expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
const berater = (await screen.findByLabelText('Beraternummer')) as HTMLInputElement;
expect(berater.value).toBe('');
expect(screen.getByLabelText('Mandantennummer')).toBeTruthy();
expect(screen.getByLabelText('Lohnart')).toBeTruthy();
});
it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => {
mockGetSettings.mockResolvedValue(EMPTY);
render(<KantineDatevPage />);
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
});
describe('KantineDatevPage — Abrechnung', () => {
it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW);
render(<KantineDatevPage />);
await upload();
expect(await screen.findByText('03/2026')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
expect(screen.getByText(/123,45/)).toBeTruthy();
expect(screen.getByText('Die hochgeladenen Daten werden nicht gespeichert.')).toBeTruthy();
});
it('listet Warnungen und Fehler mit Zeilennummer, Download gesperrt', async () => {
mockPreview.mockResolvedValue({
...GOOD_PREVIEW,
canExport: false,
blockedReason: 'errors',
warnings: [
{
code: 'multipleMonths',
message: 'x',
params: { months: ['03/2026', '04/2026'] },
},
],
errors: [{ row: 7, field: 'betrag', code: 'betragFormat', message: 'Betrag muss ...' }],
});
render(<KantineDatevPage />);
await upload();
const table = await screen.findByRole('table');
const row = within(table).getByText('7').closest('tr') as HTMLElement;
expect(within(row).getByText('Betrag')).toBeTruthy();
expect(within(row).getByText(/deutschen Zahlenformat/)).toBeTruthy();
expect(
screen.getByText(/Verschiedene Abrechnungsmonate erkannt: 03\/2026, 04\/2026/),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: 'DATEV-Datei herunterladen' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('sperrt den Download, solange nichts hinterlegt ist', async () => {
mockGetSettings.mockResolvedValue(EMPTY);
mockPreview.mockResolvedValue({
...GOOD_PREVIEW,
canExport: false,
blockedReason: 'settingsMissing',
});
render(<KantineDatevPage />);
await upload();
const button = (await screen.findByRole('button', {
name: 'DATEV-Datei herunterladen',
})) as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it('Klick auf Download ruft den Export mit derselben Datei und danach downloadBase64', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW);
mockExport.mockResolvedValue({
filename: 'LuG_1_2_03_2026.sic',
content: 'QUJD',
mimeType: 'text/plain',
});
render(<KantineDatevPage />);
const file = csvFile();
await upload(file);
const button = (await screen.findByRole('button', {
name: 'DATEV-Datei herunterladen',
})) as HTMLButtonElement;
await waitFor(() => expect(button.disabled).toBe(false));
fireEvent.click(button);
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('LuG_1_2_03_2026.sic', 'QUJD', 'text/plain'),
);
expect(mockExport).toHaveBeenCalledWith(file);
expect(await screen.findByText(/LuG_1_2_03_2026\.sic/)).toBeTruthy();
});
});
describe('KantineDatevPage — Einstellungen speichern', () => {
it('lehnt Buchstaben vor dem Senden ab und speichert Ziffern', async () => {
mockUser('ADMIN');
mockGetSettings.mockResolvedValue(EMPTY);
mockSaveSettings.mockResolvedValue(CONFIGURED);
render(<KantineDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
fireEvent.change(await screen.findByLabelText('Beraternummer'), { target: { value: '12a' } });
fireEvent.change(screen.getByLabelText('Mandantennummer'), { target: { value: '12345' } });
fireEvent.change(screen.getByLabelText('Lohnart'), { target: { value: '1111' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte nur Ziffern eingeben (1 bis 10 Stellen).')).toBeTruthy();
expect(mockSaveSettings).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Beraternummer'), { target: { value: '1234567' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(mockSaveSettings).toHaveBeenCalledWith({
beraterNr: '1234567',
mandantNr: '12345',
lohnart: '1111',
}),
);
expect(await screen.findByText('Die Einstellungen wurden gespeichert.')).toBeTruthy();
});
});
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function KantineDatevLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="kantine-datev">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,364 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { FileDropArea } from '@/components/accounting/file-drop-area';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { downloadBase64 } from '@/lib/download-base64';
import {
exportKantineCsv,
getKantineSettings,
type KantinePreview,
KantineRequestError,
type KantineSettings,
previewKantineCsv,
saveKantineSettings,
} from '@/lib/kantine-datev-api';
import { useAuthStore } from '@/lib/stores/auth-store';
type TabId = 'billing' | 'settings';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
/**
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV hochladen, pruefen und
* als DATEV-Lohndatei herunterladen. Die Datei bleibt im Browser (State) und
* wird fuer Vorschau UND Export an die API geschickt — die API speichert nichts.
*/
export default function KantineDatevPage() {
const t = useTranslations('kantineDatev');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [tab, setTab] = useState<TabId>('billing');
const [settings, setSettings] = useState<KantineSettings | null>(null);
const [settingsError, setSettingsError] = useState(false);
useEffect(() => {
let cancelled = false;
getKantineSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch(() => {
if (!cancelled) setSettingsError(true);
});
return () => {
cancelled = true;
};
}, []);
const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }];
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab;
return (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="kantine-datev" title={t('title')} description={t('description')} />
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'billing' ? (
<BillingTab
settings={settings}
settingsError={settingsError}
isAdmin={isAdmin}
onOpenSettings={() => setTab('settings')}
/>
) : (
<SettingsTab settings={settings} onSaved={setSettings} />
)}
</div>
</div>
);
}
function BillingTab({
settings,
settingsError,
isAdmin,
onOpenSettings,
}: {
settings: KantineSettings | null;
settingsError: boolean;
isAdmin: boolean;
onOpenSettings: () => void;
}) {
const t = useTranslations('kantineDatev');
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<KantinePreview | null>(null);
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
const requestError = useCallback(
(error: unknown): string => {
if (error instanceof KantineRequestError) {
if (error.code && t.has(`errors.${error.code}`)) return t(`errors.${error.code}`);
return error.message;
}
return t('errors.request');
},
[t],
);
const handleFile = async (selected: File) => {
setFile(selected);
setPreview(null);
setMessage(null);
setChecking(true);
try {
setPreview(await previewKantineCsv(selected));
} catch (error) {
setMessage({ kind: 'error', text: requestError(error) });
} finally {
setChecking(false);
}
};
const handleClear = () => {
setFile(null);
setPreview(null);
setMessage(null);
};
const handleDownload = async () => {
if (!file) return;
setDownloading(true);
setMessage(null);
try {
const result = await exportKantineCsv(file);
downloadBase64(result.filename, result.content, result.mimeType);
setMessage({ kind: 'ok', text: t('download.done', { filename: result.filename }) });
} catch (error) {
setMessage({ kind: 'error', text: requestError(error) });
} finally {
setDownloading(false);
}
};
const notConfigured = settings !== null && !settings.configured;
const canDownload = Boolean(preview?.canExport) && !downloading && !checking;
return (
<div className="space-y-5">
{settingsError && <p className="text-sm text-destructive">{t('errors.request')}</p>}
{notConfigured && (
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
<p>{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{isAdmin && (
<button
type="button"
onClick={onOpenSettings}
className="font-medium underline underline-offset-2"
>
{t('notConfigured.adminAction')}
</button>
)}
</div>
)}
<FileDropArea
accept=".csv,text/csv"
file={file}
onFile={handleFile}
onClear={handleClear}
placeholder={t('dropZone.placeholder')}
hint={t('dropZone.formats')}
clearLabel={t('dropZone.clear')}
/>
<p className="text-xs text-muted-foreground">{t('noStorage')}</p>
{checking && <p className="text-sm text-muted-foreground">{t('checking')}</p>}
{preview && (
<div className="space-y-5">
<section aria-label={t('summary.title')}>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SummaryItem label={t('summary.rows')} value={String(preview.rowCount)} />
<SummaryItem
label={t('summary.month')}
value={preview.abrechnungsMonat ?? t('summary.noMonth')}
/>
<SummaryItem
label={t('summary.total')}
value={euro.format(preview.totalCents / 100)}
/>
</dl>
</section>
{preview.warnings.length > 0 && (
<section className="space-y-1">
<h2 className="text-sm font-semibold text-foreground">{t('warnings.title')}</h2>
<ul className="space-y-1 text-sm text-status-warn-fg">
{preview.warnings.map((w) => (
<li key={`${w.code}-${w.message}`}>
{w.code === 'multipleMonths' && w.params?.months
? t('warnings.multipleMonths', { months: w.params.months.join(', ') })
: w.message}
</li>
))}
</ul>
</section>
)}
{preview.errors.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-semibold text-destructive">{t('errors.title')}</h2>
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('errors.line')}</th>
<th className="px-3 py-2 font-medium">{t('errors.field')}</th>
<th className="px-3 py-2 font-medium">{t('errors.message')}</th>
</tr>
</thead>
<tbody>
{preview.errors.map((e) => (
<tr key={`${e.row}-${e.field}-${e.code}`} className="border-t border-border">
<td className="px-3 py-2 tabular-nums">{e.row}</td>
<td className="px-3 py-2">
{t.has(`fields.${e.field}`) ? t(`fields.${e.field}`) : e.field}
</td>
<td className="px-3 py-2">
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<button
type="button"
className={BUTTON_CLASS}
disabled={!canDownload}
onClick={handleDownload}
>
{downloading ? t('download.busy') : t('download.button')}
</button>
</div>
)}
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
</div>
);
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold text-foreground">{value}</dd>
</div>
);
}
function SettingsTab({
settings,
onSaved,
}: {
settings: KantineSettings | null;
onSaved: (s: KantineSettings) => void;
}) {
const t = useTranslations('kantineDatev');
const [beraterNr, setBeraterNr] = useState('');
const [mandantNr, setMandantNr] = useState('');
const [lohnart, setLohnart] = useState('');
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
// Gespeicherte Werte einmal ins Formular uebernehmen, sobald sie geladen sind.
useEffect(() => {
if (settings) {
setBeraterNr(settings.beraterNr ?? '');
setMandantNr(settings.mandantNr ?? '');
setLohnart(settings.lohnart ?? '');
}
}, [settings]);
const valid = (v: string) => /^\d{1,10}$/.test(v);
const allValid = valid(beraterNr) && valid(mandantNr) && valid(lohnart);
const handleSave = async (event: React.FormEvent) => {
event.preventDefault();
setTouched(true);
if (!allValid) return;
setSaving(true);
setMessage(null);
try {
const saved = await saveKantineSettings({ beraterNr, mandantNr, lohnart });
onSaved(saved);
setMessage({ kind: 'ok', text: t('settings.saved') });
} catch (error) {
setMessage({
kind: 'error',
text: error instanceof KantineRequestError ? error.message : t('errors.request'),
});
} finally {
setSaving(false);
}
};
const fields = [
{ id: 'beraterNr', label: t('settings.beraterNr'), value: beraterNr, set: setBeraterNr },
{ id: 'mandantNr', label: t('settings.mandantNr'), value: mandantNr, set: setMandantNr },
{ id: 'lohnart', label: t('settings.lohnart'), value: lohnart, set: setLohnart },
];
return (
<form onSubmit={handleSave} className="max-w-md space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{t('settings.intro')}</p>
{fields.map((f) => {
const showError = touched && !valid(f.value);
return (
<div key={f.id} className="space-y-1">
<label htmlFor={`kantine-${f.id}`} className="text-sm font-medium text-foreground">
{f.label}
</label>
<input
id={`kantine-${f.id}`}
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={10}
value={f.value}
aria-invalid={showError}
onChange={(e) => f.set(e.target.value.trim())}
className={INPUT_CLASS}
/>
{showError && <p className="text-xs text-destructive">{t('settings.digitsOnly')}</p>}
</div>
);
})}
<button type="submit" className={BUTTON_CLASS} disabled={saving}>
{saving ? t('settings.saving') : t('settings.save')}
</button>
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
</form>
);
}
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import CertManagerLayout from './cert-manager/layout';
import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout';
import KantineDatevLayout from './kantine-datev/layout';
import TenderRadarLayout from './tender-radar/layout';
/**
@@ -38,6 +39,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['dkv-fleet', DkvFleetLayout],
['domaincheck', DomaincheckLayout],
['tender-radar', TenderRadarLayout],
['kantine-datev', KantineDatevLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild });