feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen

- Reiter Import: Vorschau mit neu-Markierung, Buchungsdatum (TTMM) aus dem Dateinamen, Download speichert neue Konten
- Reiter Konten: anlegen, bearbeiten, loeschen mit Rueckfrage, CSV-Import (ersetzt alles, nach Rueckfrage) und -Export
- Reiter Einstellungen nur fuer Administratoren; Texte de/en

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:47:09 +02:00
parent 44c1d4351f
commit 42b89f110b
15 changed files with 1726 additions and 1 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* Handelsware — API-Client (quick-261002-fm5). Konsumiert
* `/modules/handelsware-datev`. Die Excel-Datei geht als Multipart-Feld `file`;
* beim Export zusaetzlich `buchungsdatum` und `newAccounts` (JSON-Text der von
* der Vorschau gemeldeten neuen Konten) — die API liest alle Zeilen erneut aus
* derselben Datei, damit die TXT nicht von der Arbeitsmappe abweichen kann.
*/
import { accountingRequest, type FileResponse } from '@/lib/accounting-request';
export type { FileResponse } from '@/lib/accounting-request';
export { AccountingRequestError as HandelswareRequestError } from '@/lib/accounting-request';
const BASE = '/modules/handelsware-datev';
export interface HandelswareSettings {
erloeskonto: number | null;
startGegenkonto: number | null;
configured: boolean;
}
export interface HandelswareSettingsInput {
erloeskonto: number;
startGegenkonto: number;
}
export interface HandelswareAccount {
id: string;
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface HandelswareAccountInput {
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface NewAccount {
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface PreviewRow {
line: number;
buchungstext: string;
umsatz: string;
sollHaben: 'S' | 'H';
gegenkonto: number;
erloeskonto: number;
isNew: boolean;
}
export interface RowError {
line: number;
code: string;
message: string;
}
export interface HandelswarePreview {
headerText: string;
suggestedBuchungsdatum: string;
exportFilename: string;
rows: PreviewRow[];
newAccounts: NewAccount[];
rowErrors: RowError[];
}
export type HandelswareExportResult = FileResponse & { createdCount: number };
const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29) — wie die API. */
export function isValidBuchungsdatum(ttmm: string): boolean {
if (!/^\d{4}$/.test(ttmm)) return false;
const day = Number.parseInt(ttmm.slice(0, 2), 10);
const month = Number.parseInt(ttmm.slice(2, 4), 10);
if (month < 1 || month > 12) return false;
return day >= 1 && day <= DAYS_PER_MONTH[month - 1];
}
function fileForm(file: File): FormData {
const form = new FormData();
form.append('file', file);
return form;
}
export function getHandelswareSettings(): Promise<HandelswareSettings> {
return accountingRequest(`${BASE}/settings`);
}
export function saveHandelswareSettings(
input: HandelswareSettingsInput,
): Promise<HandelswareSettings> {
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
}
export function previewHandelsware(file: File): Promise<HandelswarePreview> {
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
}
export function exportHandelsware(
file: File,
buchungsdatum: string,
newAccounts: NewAccount[],
): Promise<HandelswareExportResult> {
const form = fileForm(file);
form.append('buchungsdatum', buchungsdatum);
form.append('newAccounts', JSON.stringify(newAccounts));
return accountingRequest(`${BASE}/export`, { method: 'POST', form });
}
export function listAccounts(): Promise<HandelswareAccount[]> {
return accountingRequest(`${BASE}/accounts`);
}
export function createAccount(input: HandelswareAccountInput): Promise<HandelswareAccount> {
return accountingRequest(`${BASE}/accounts`, { method: 'POST', json: input });
}
export function updateAccount(
id: string,
input: HandelswareAccountInput,
): Promise<HandelswareAccount> {
return accountingRequest(`${BASE}/accounts/${encodeURIComponent(id)}`, {
method: 'PUT',
json: input,
});
}
export function deleteAccount(id: string): Promise<{ deleted: true }> {
return accountingRequest(`${BASE}/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export function importAccountsCsv(file: File): Promise<{ count: number }> {
return accountingRequest(`${BASE}/accounts/import-csv`, { method: 'POST', form: fileForm(file) });
}
export function exportAccountsCsv(): Promise<FileResponse> {
return accountingRequest(`${BASE}/accounts/export-csv`);
}
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'tile';
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'tile';
const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar',
@@ -16,6 +16,7 @@ const ICONS: Record<string, ModuleIconId> = {
domaincheck: 'globe',
proxmox: 'server',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
};
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+6
View File
@@ -65,6 +65,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false },
),
},
'handelsware-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/handelsware-datev/page'),
{ ssr: false },
),
},
};
/**
+1
View File
@@ -30,6 +30,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'dkv-fleet': 'dkvFleet.pageTitle',
'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title',
};
/**