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
+70
View File
@@ -0,0 +1,70 @@
/**
* Gemeinsame Anfragehilfen der Finanzbuchhaltungs-Module (Kantinenabrechnung,
* Handelsware; quick-261002-fm5). `credentials: 'include'` fuer Cookie-Auth,
* `NEXT_PUBLIC_API_URL` als Basis — Muster `custom-modules-api.ts`.
*/
export const ACCOUNTING_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Dateiantwort der API (Base64) — wie beim Zertifikat-Manager. */
export interface FileResponse {
filename: string;
content: string;
mimeType: string;
}
/**
* Fehler mit HTTP-Status, maschinenlesbarer Kennung (`code`, falls die API
* eine liefert) und deutscher Servermeldung.
*/
export class AccountingRequestError extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
readonly details: unknown = null,
) {
super(message);
this.name = 'AccountingRequestError';
}
}
async function failure(res: Response): Promise<AccountingRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
let details: unknown = null;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
if (typeof body?.code === 'string') code = body.code;
details = body?.errors ?? null;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new AccountingRequestError(res.status, code, message, details);
}
export async function accountingRequest<T>(
path: string,
init: { method?: string; json?: unknown; form?: FormData } = {},
): Promise<T> {
const headers: Record<string, string> = {};
let body: BodyInit | undefined;
if (init.form) {
body = init.form;
} else if (init.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(init.json);
}
const res = await fetch(`${ACCOUNTING_API_URL}${path}`, {
method: init.method ?? 'GET',
credentials: 'include',
headers,
body,
});
if (!res.ok) throw await failure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Startet den Download einer Base64-kodierten Datei im Browser (quick-261002-fm5).
*
* Clientseitiger Blob-Download: Object-URL plus Anker mit `download`-Attribut.
* Genau dieser Weg wird vom Desktop-Client seit 1.9.2 als Datei gespeichert —
* deshalb keine Tauri-spezifische Sonderbehandlung. Gleicher Koerper wie
* `downloadBase64` im Zertifikat-Manager.
*/
export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content);
const byteArray = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
byteArray[i] = bytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Kantinenabrechnung — API-Client (quick-261002-fm5). Konsumiert
* `/modules/kantine-datev`. Hochgeladene Dateien gehen als Multipart-Feld
* `file`; die API speichert sie nicht.
*/
import { accountingRequest, type FileResponse } from '@/lib/accounting-request';
export type { FileResponse } from '@/lib/accounting-request';
export { AccountingRequestError as KantineRequestError } from '@/lib/accounting-request';
const BASE = '/modules/kantine-datev';
export interface KantineSettings {
beraterNr: string | null;
mandantNr: string | null;
lohnart: string | null;
configured: boolean;
}
export interface KantineSettingsInput {
beraterNr: string;
mandantNr: string;
lohnart: string;
}
export interface KantineIssue {
row: number;
field: string;
code: string;
message: string;
}
export interface KantineWarning {
code: string;
message: string;
params?: { months?: string[] };
}
export interface KantinePreview {
rowCount: number;
abrechnungsMonat: string | null;
totalCents: number;
errors: KantineIssue[];
warnings: KantineWarning[];
canExport: boolean;
blockedReason: 'settingsMissing' | 'errors' | null;
}
function fileForm(file: File): FormData {
const form = new FormData();
form.append('file', file);
return form;
}
export function getKantineSettings(): Promise<KantineSettings> {
return accountingRequest(`${BASE}/settings`);
}
export function saveKantineSettings(input: KantineSettingsInput): Promise<KantineSettings> {
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
}
export function previewKantineCsv(file: File): Promise<KantinePreview> {
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
}
export function exportKantineCsv(file: File): Promise<FileResponse> {
return accountingRequest(`${BASE}/export`, { method: 'POST', form: fileForm(file) });
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -15,6 +15,7 @@ const ICONS: Record<string, ModuleIconId> = {
'cert-manager': 'certificate',
domaincheck: 'globe',
proxmox: 'server',
'kantine-datev': 'utensils',
};
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+6
View File
@@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
'kantine-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/kantine-datev/page'),
{ ssr: false },
),
},
};
/**
+1
View File
@@ -29,6 +29,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'cert-manager': 'certManager.title',
'dkv-fleet': 'dkvFleet.pageTitle',
'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title',
};
/**