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
@@ -0,0 +1,461 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { downloadBase64 } from '@/lib/download-base64';
import {
createAccount,
deleteAccount,
exportAccountsCsv,
type HandelswareAccount,
HandelswareRequestError,
type HandelswareSettings,
importAccountsCsv,
listAccounts,
updateAccount,
} from '@/lib/handelsware-datev-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON_CLASS =
'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
const LINK_BUTTON_CLASS =
'text-sm font-medium text-foreground underline underline-offset-2 hover:opacity-80';
interface Draft {
name: string;
gegenkonto: string;
erloeskonto: string;
}
const EMPTY_DRAFT: Draft = { name: '', gegenkonto: '', erloeskonto: '' };
function parseNumber(text: string): number | null {
if (!/^\d{1,9}$/.test(text.trim())) return null;
const n = Number.parseInt(text.trim(), 10);
return n >= 1 ? n : null;
}
interface ImportLineError {
line: number;
code: string;
message: string;
}
/**
* Reiter "Konten": Kontenliste anzeigen, anlegen, bearbeiten, loeschen (mit
* Rueckfrage), CSV importieren (ersetzt ALLE Konten, nach Rueckfrage) und
* exportieren. Alle Benutzer mit Modulzugriff duerfen die Liste pflegen.
*/
export function AccountsTab({
settings,
reloadKey,
}: {
settings: HandelswareSettings | null;
reloadKey: number;
}) {
const t = useTranslations('handelswareDatev');
const [accounts, setAccounts] = useState<HandelswareAccount[] | null>(null);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
const [importErrors, setImportErrors] = useState<ImportLineError[]>([]);
const [newDraft, setNewDraft] = useState<Draft>(EMPTY_DRAFT);
const [editId, setEditId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Draft>(EMPTY_DRAFT);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [pendingImport, setPendingImport] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const errorText = useCallback(
(error: unknown): string => {
if (error instanceof HandelswareRequestError) {
return error.code && t.has(`errors.${error.code}`)
? t(`errors.${error.code}`)
: error.message;
}
return t('errors.request');
},
[t],
);
// reloadKey: der Import-Reiter zaehlt hoch, wenn ein Export neue Konten gespeichert hat.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey ist der gewollte Ausloeser.
useEffect(() => {
let cancelled = false;
listAccounts()
.then((list) => {
if (!cancelled) setAccounts(list);
})
.catch((error) => {
if (!cancelled) setMessage({ kind: 'error', text: errorText(error) });
});
return () => {
cancelled = true;
};
}, [reloadKey, errorText]);
// Das Standard-Erloeskonto des Mandanten erleichtert das Anlegen (Vorbelegung, kein Festwert).
const defaultErloeskonto = settings?.erloeskonto != null ? String(settings.erloeskonto) : '';
const toInput = (draft: Draft) => {
const gegenkonto = parseNumber(draft.gegenkonto);
const erloeskonto = parseNumber(draft.erloeskonto || defaultErloeskonto);
return { name: draft.name.trim(), gegenkonto, erloeskonto };
};
const handleAdd = async (event: React.FormEvent) => {
event.preventDefault();
const { name, gegenkonto, erloeskonto } = toInput(newDraft);
if (name === '') return setMessage({ kind: 'error', text: t('accounts.nameRequired') });
if (gegenkonto === null || erloeskonto === null) {
return setMessage({ kind: 'error', text: t('accounts.invalidNumber') });
}
setBusy(true);
setMessage(null);
try {
const created = await createAccount({ name, gegenkonto, erloeskonto });
setAccounts((list) =>
[...(list ?? []), created].sort((a, b) => a.name.localeCompare(b.name)),
);
setNewDraft(EMPTY_DRAFT);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const startEdit = (account: HandelswareAccount) => {
setEditId(account.id);
setEditDraft({
name: account.name,
gegenkonto: String(account.gegenkonto),
erloeskonto: String(account.erloeskonto),
});
setDeleteId(null);
setMessage(null);
};
const handleSaveEdit = async () => {
if (!editId) return;
const { name, gegenkonto, erloeskonto } = toInput(editDraft);
if (name === '') return setMessage({ kind: 'error', text: t('accounts.nameRequired') });
if (gegenkonto === null || erloeskonto === null) {
return setMessage({ kind: 'error', text: t('accounts.invalidNumber') });
}
setBusy(true);
setMessage(null);
try {
const updated = await updateAccount(editId, { name, gegenkonto, erloeskonto });
setAccounts((list) =>
(list ?? [])
.map((a) => (a.id === updated.id ? updated : a))
.sort((a, b) => a.name.localeCompare(b.name)),
);
setEditId(null);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const handleDelete = async (id: string) => {
setBusy(true);
setMessage(null);
try {
await deleteAccount(id);
setAccounts((list) => (list ?? []).filter((a) => a.id !== id));
setDeleteId(null);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const handleExportCsv = async () => {
setMessage(null);
try {
const result = await exportAccountsCsv();
downloadBase64(result.filename, result.content, result.mimeType);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
}
};
const handleConfirmImport = async () => {
if (!pendingImport) return;
setBusy(true);
setMessage(null);
setImportErrors([]);
try {
const result = await importAccountsCsv(pendingImport);
setMessage({ kind: 'ok', text: t('accounts.importDone', { count: result.count }) });
setAccounts(await listAccounts());
} catch (error) {
if (error instanceof HandelswareRequestError && Array.isArray(error.details)) {
setImportErrors(error.details as ImportLineError[]);
setMessage({ kind: 'error', text: t('accounts.importErrors') });
} else {
setMessage({ kind: 'error', text: errorText(error) });
}
} finally {
setPendingImport(null);
setBusy(false);
}
};
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="accounts-csv-input"
onChange={(e) => {
const picked = e.target.files?.[0];
if (picked) {
setPendingImport(picked);
setMessage(null);
setImportErrors([]);
}
e.target.value = '';
}}
/>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={() => fileInputRef.current?.click()}
disabled={busy}
>
{t('accounts.importCsv')}
</button>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={handleExportCsv}
disabled={busy}
>
{t('accounts.exportCsv')}
</button>
<span className="text-xs text-muted-foreground">{t('accounts.csvHint')}</span>
</div>
{pendingImport && (
<div
role="alertdialog"
aria-label={t('accounts.importReplace')}
className="space-y-3 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground"
>
<p>
{t('accounts.importConfirm', {
count: accounts?.length ?? 0,
file: pendingImport.name,
})}
</p>
<div className="flex gap-2">
<button
type="button"
className={BUTTON_CLASS}
onClick={handleConfirmImport}
disabled={busy}
>
{t('accounts.importReplace')}
</button>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={() => setPendingImport(null)}
disabled={busy}
>
{t('accounts.cancel')}
</button>
</div>
</div>
)}
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
{importErrors.length > 0 && (
<ul className="space-y-0.5 text-sm text-destructive">
{importErrors.map((e) => (
<li key={`${e.line}-${e.code}`}>
{t('import.rowErrorLine', { line: e.line })}:{' '}
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</li>
))}
</ul>
)}
<form
onSubmit={handleAdd}
className="grid gap-2 sm:grid-cols-[1fr_8rem_8rem_auto]"
noValidate
>
<input
aria-label={t('accounts.name')}
placeholder={t('accounts.namePlaceholder')}
maxLength={120}
value={newDraft.name}
onChange={(e) => setNewDraft({ ...newDraft, name: e.target.value })}
className={INPUT_CLASS}
/>
<input
aria-label={t('accounts.counter')}
inputMode="numeric"
maxLength={9}
value={newDraft.gegenkonto}
onChange={(e) => setNewDraft({ ...newDraft, gegenkonto: e.target.value.trim() })}
className={INPUT_CLASS}
/>
<input
aria-label={t('accounts.revenue')}
inputMode="numeric"
maxLength={9}
value={newDraft.erloeskonto || defaultErloeskonto}
onChange={(e) => setNewDraft({ ...newDraft, erloeskonto: e.target.value.trim() })}
className={INPUT_CLASS}
/>
<button type="submit" className={BUTTON_CLASS} disabled={busy}>
{t('accounts.add')}
</button>
</form>
{accounts === null ? (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
) : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('accounts.empty')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('accounts.name')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.counter')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.revenue')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.actions')}</th>
</tr>
</thead>
<tbody>
{accounts.map((account) =>
editId === account.id ? (
<tr key={account.id} className="border-t border-border">
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.name')}
maxLength={120}
value={editDraft.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
className={INPUT_CLASS}
/>
</td>
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.counter')}
inputMode="numeric"
maxLength={9}
value={editDraft.gegenkonto}
onChange={(e) =>
setEditDraft({ ...editDraft, gegenkonto: e.target.value.trim() })
}
className={INPUT_CLASS}
/>
</td>
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.revenue')}
inputMode="numeric"
maxLength={9}
value={editDraft.erloeskonto}
onChange={(e) =>
setEditDraft({ ...editDraft, erloeskonto: e.target.value.trim() })
}
className={INPUT_CLASS}
/>
</td>
<td className="space-x-3 px-3 py-1.5">
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={handleSaveEdit}
disabled={busy}
>
{t('accounts.save')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => setEditId(null)}
>
{t('accounts.cancel')}
</button>
</td>
</tr>
) : (
<tr key={account.id} className="border-t border-border">
<td className="px-3 py-1.5">{account.name}</td>
<td className="px-3 py-1.5 tabular-nums">{account.gegenkonto}</td>
<td className="px-3 py-1.5 tabular-nums">{account.erloeskonto}</td>
<td className="px-3 py-1.5">
{deleteId === account.id ? (
<span className="flex flex-wrap items-center gap-3">
<span>{t('accounts.deleteConfirm', { name: account.name })}</span>
<button
type="button"
className="text-sm font-medium text-destructive underline underline-offset-2"
onClick={() => handleDelete(account.id)}
disabled={busy}
>
{t('accounts.deleteYes')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => setDeleteId(null)}
>
{t('accounts.cancel')}
</button>
</span>
) : (
<span className="space-x-3">
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => startEdit(account)}
>
{t('accounts.edit')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => {
setDeleteId(account.id);
setEditId(null);
}}
>
{t('accounts.delete')}
</button>
</span>
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,320 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useState } from 'react';
import { FileDropArea } from '@/components/accounting/file-drop-area';
import { downloadBase64 } from '@/lib/download-base64';
import {
exportHandelsware,
type HandelswarePreview,
HandelswareRequestError,
type HandelswareSettings,
isValidBuchungsdatum,
previewHandelsware,
} from '@/lib/handelsware-datev-api';
const INPUT_CLASS =
'w-28 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 SECONDARY_BUTTON_CLASS =
'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted';
/** Anzeigegrenze der Vorschautabelle — die Datei selbst wird vollstaendig verarbeitet. */
const MAX_VISIBLE_ROWS = 1000;
const amountFormat = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function toCents(amount: string): number {
return Math.round(Number(amount) * 100);
}
type Message = { kind: 'error' | 'ok'; text: string; code?: string | null };
/**
* Reiter "Import": Excel-Datei hochladen, Vorschau mit Kontenzuordnung,
* Buchungsdatum (TTMM) bearbeiten, TXT herunterladen. Neue Konten werden erst
* beim Herunterladen gespeichert (die API vergleicht dazu die Liste neu).
*/
export function ImportTab({
settings,
isAdmin,
onOpenSettings,
onAccountsChanged,
}: {
settings: HandelswareSettings | null;
isAdmin: boolean;
onOpenSettings: () => void;
onAccountsChanged: () => void;
}) {
const t = useTranslations('handelswareDatev');
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<HandelswarePreview | null>(null);
const [buchungsdatum, setBuchungsdatum] = useState('');
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
const requestMessage = useCallback(
(error: unknown): Message => {
if (error instanceof HandelswareRequestError) {
const text =
error.code && t.has(`errors.${error.code}`) ? t(`errors.${error.code}`) : error.message;
return { kind: 'error', text, code: error.code };
}
return { kind: 'error', text: t('errors.request') };
},
[t],
);
const loadPreview = useCallback(
async (selected: File, keepDate: boolean) => {
setChecking(true);
try {
const result = await previewHandelsware(selected);
setPreview(result);
if (!keepDate) setBuchungsdatum(result.suggestedBuchungsdatum);
return true;
} catch (error) {
setPreview(null);
setMessage(requestMessage(error));
return false;
} finally {
setChecking(false);
}
},
[requestMessage],
);
const handleFile = async (selected: File) => {
setFile(selected);
setPreview(null);
setMessage(null);
await loadPreview(selected, false);
};
const handleClear = () => {
setFile(null);
setPreview(null);
setMessage(null);
setBuchungsdatum('');
};
const handleReload = async () => {
if (!file) return;
setMessage(null);
await loadPreview(file, true);
};
const dateValid = isValidBuchungsdatum(buchungsdatum);
const canDownload =
Boolean(preview) &&
(preview?.rows.length ?? 0) > 0 &&
(preview?.rowErrors.length ?? 0) === 0 &&
dateValid &&
!downloading &&
!checking;
const handleDownload = async () => {
if (!file || !preview) return;
setDownloading(true);
setMessage(null);
try {
const result = await exportHandelsware(file, buchungsdatum, preview.newAccounts);
downloadBase64(result.filename, result.content, result.mimeType);
setMessage({
kind: 'ok',
text: t('import.downloaded', { filename: result.filename, count: result.createdCount }),
});
onAccountsChanged();
// Die neuen Konten sind jetzt gespeichert — Vorschau ohne "neu"-Markierung nachladen.
await loadPreview(file, true);
} catch (error) {
setMessage(requestMessage(error));
} finally {
setDownloading(false);
}
};
const notConfigured = settings !== null && !settings.configured;
const blockedBySettings = notConfigured || message?.code === 'settingsMissing';
let debit = 0;
let credit = 0;
for (const row of preview?.rows ?? []) {
if (row.sollHaben === 'S') debit += toCents(row.umsatz);
else credit += toCents(row.umsatz);
}
const visibleRows = preview?.rows.slice(0, MAX_VISIBLE_ROWS) ?? [];
return (
<div className="space-y-5">
{blockedBySettings && (
<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=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
file={file}
onFile={handleFile}
onClear={handleClear}
placeholder={t('import.dropPlaceholder')}
hint={t('import.dropFormats')}
clearLabel={t('import.clear')}
/>
{checking && <p className="text-sm text-muted-foreground">{t('import.checking')}</p>}
{preview && (
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
<div className="space-y-1">
<label
htmlFor="handelsware-buchungsdatum"
className="text-sm font-medium text-foreground"
>
{t('import.date')}
</label>
<input
id="handelsware-buchungsdatum"
type="text"
inputMode="numeric"
maxLength={4}
autoComplete="off"
value={buchungsdatum}
aria-invalid={!dateValid}
onChange={(e) => setBuchungsdatum(e.target.value.trim())}
className={INPUT_CLASS}
/>
{!dateValid && <p className="text-xs text-destructive">{t('import.dateInvalid')}</p>}
<p className="text-xs text-muted-foreground">{t('import.dateHint')}</p>
</div>
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="text-muted-foreground">{t('import.headerText')}:</dt>
<dd className="text-foreground">{preview.headerText || '—'}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">{t('import.exportFilename')}:</dt>
<dd className="text-foreground">{preview.exportFilename}</dd>
</div>
</dl>
</div>
{preview.rowErrors.length > 0 && (
<section className="space-y-1">
<h2 className="text-sm font-semibold text-destructive">
{t('import.rowErrorsTitle')}
</h2>
<ul className="space-y-0.5 text-sm text-destructive">
{preview.rowErrors.map((e) => (
<li key={`${e.line}-${e.code}`}>
{t('import.rowErrorLine', { line: e.line })}:{' '}
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</li>
))}
</ul>
</section>
)}
{preview.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('import.noRows')}</p>
) : (
<>
<p className="text-sm text-foreground">
{preview.newAccounts.length > 0
? t('import.newSummary', { count: preview.newAccounts.length })
: t('import.noNewAccounts')}
</p>
<div className="max-h-[28rem] overflow-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('import.columns.text')}</th>
<th className="px-3 py-2 text-right font-medium">
{t('import.columns.amount')}
</th>
<th className="px-3 py-2 font-medium">{t('import.columns.sh')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.counter')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.date')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.revenue')}</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => (
<tr key={row.line} className="border-t border-border">
<td className="px-3 py-1.5">
{row.buchungstext}
{row.isNew && (
<span className="ml-2 rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
{t('import.newBadge')}
</span>
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">{row.umsatz}</td>
<td className="px-3 py-1.5">{row.sollHaben}</td>
<td className="px-3 py-1.5 tabular-nums">{row.gegenkonto}</td>
<td className="px-3 py-1.5 tabular-nums">{buchungsdatum}</td>
<td className="px-3 py-1.5 tabular-nums">{row.erloeskonto}</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.rows.length > MAX_VISIBLE_ROWS && (
<p className="text-xs text-muted-foreground">
{t('import.truncated', { shown: MAX_VISIBLE_ROWS, total: preview.rows.length })}
</p>
)}
<p className="text-sm text-muted-foreground">
{t('import.totals', {
debit: amountFormat.format(debit / 100),
credit: amountFormat.format(credit / 100),
})}
</p>
</>
)}
<button
type="button"
className={BUTTON_CLASS}
disabled={!canDownload}
onClick={handleDownload}
>
{downloading ? t('import.downloading') : t('import.download')}
</button>
</div>
)}
{message && (
<div className="space-y-2">
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
{message.code === 'accountsChanged' && file && (
<button type="button" className={SECONDARY_BUTTON_CLASS} onClick={handleReload}>
{t('import.reloadPreview')}
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import {
HandelswareRequestError,
type HandelswareSettings,
saveHandelswareSettings,
} from '@/lib/handelsware-datev-api';
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';
function toNumber(text: string): number | null {
if (!/^\d{1,9}$/.test(text)) return null;
const n = Number.parseInt(text, 10);
return n >= 1 ? n : null;
}
/** Einstellungen der Handelsware — nur fuer Administratoren sichtbar (Reiter). */
export function SettingsTab({
settings,
onSaved,
}: {
settings: HandelswareSettings | null;
onSaved: (s: HandelswareSettings) => void;
}) {
const t = useTranslations('handelswareDatev');
const [erloeskonto, setErloeskonto] = useState('');
const [startGegenkonto, setStartGegenkonto] = useState('');
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
useEffect(() => {
if (settings) {
setErloeskonto(settings.erloeskonto === null ? '' : String(settings.erloeskonto));
setStartGegenkonto(settings.startGegenkonto === null ? '' : String(settings.startGegenkonto));
}
}, [settings]);
const handleSave = async (event: React.FormEvent) => {
event.preventDefault();
setTouched(true);
const e = toNumber(erloeskonto);
const s = toNumber(startGegenkonto);
if (e === null || s === null) return;
setSaving(true);
setMessage(null);
try {
onSaved(await saveHandelswareSettings({ erloeskonto: e, startGegenkonto: s }));
setMessage({ kind: 'ok', text: t('settings.saved') });
} catch (error) {
setMessage({
kind: 'error',
text: error instanceof HandelswareRequestError ? error.message : t('errors.request'),
});
} finally {
setSaving(false);
}
};
const fields = [
{
id: 'erloeskonto',
label: t('settings.erloeskonto'),
help: t('settings.erloeskontoHelp'),
value: erloeskonto,
set: setErloeskonto,
},
{
id: 'startGegenkonto',
label: t('settings.startGegenkonto'),
help: t('settings.startGegenkontoHelp'),
value: startGegenkonto,
set: setStartGegenkonto,
},
];
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 && toNumber(f.value) === null;
return (
<div key={f.id} className="space-y-1">
<label htmlFor={`handelsware-${f.id}`} className="text-sm font-medium text-foreground">
{f.label}
</label>
<input
id={`handelsware-${f.id}`}
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={9}
value={f.value}
aria-invalid={showError}
onChange={(e) => f.set(e.target.value.trim())}
className={INPUT_CLASS}
/>
<p className="text-xs text-muted-foreground">{f.help}</p>
{showError && <p className="text-xs text-destructive">{t('settings.numberInvalid')}</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>
);
}
@@ -0,0 +1,378 @@
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 type { HandelswareAccount, HandelswarePreview } from '@/lib/handelsware-datev-api';
import de from '@/messages/de.json';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const api = {
getHandelswareSettings: vi.fn(),
saveHandelswareSettings: vi.fn(),
previewHandelsware: vi.fn(),
exportHandelsware: vi.fn(),
listAccounts: vi.fn(),
createAccount: vi.fn(),
updateAccount: vi.fn(),
deleteAccount: vi.fn(),
importAccountsCsv: vi.fn(),
exportAccountsCsv: vi.fn(),
};
// Echte Fehlerklasse und echte TTMM-Pruefung, nur die Netzwerkaufrufe sind ersetzt.
vi.mock('@/lib/handelsware-datev-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/handelsware-datev-api')>();
return {
...actual,
getHandelswareSettings: (...a: unknown[]) => api.getHandelswareSettings(...a),
saveHandelswareSettings: (...a: unknown[]) => api.saveHandelswareSettings(...a),
previewHandelsware: (...a: unknown[]) => api.previewHandelsware(...a),
exportHandelsware: (...a: unknown[]) => api.exportHandelsware(...a),
listAccounts: (...a: unknown[]) => api.listAccounts(...a),
createAccount: (...a: unknown[]) => api.createAccount(...a),
updateAccount: (...a: unknown[]) => api.updateAccount(...a),
deleteAccount: (...a: unknown[]) => api.deleteAccount(...a),
importAccountsCsv: (...a: unknown[]) => api.importAccountsCsv(...a),
exportAccountsCsv: (...a: unknown[]) => api.exportAccountsCsv(...a),
};
});
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 { HandelswareRequestError } from '@/lib/handelsware-datev-api';
import HandelswareDatevPage from './page';
const CONFIGURED = { erloeskonto: 4711, startGegenkonto: 2000, configured: true };
const EMPTY = { erloeskonto: null, startGegenkonto: null, configured: false };
const PREVIEW: HandelswarePreview = {
headerText: '2026',
suggestedBuchungsdatum: '3103',
exportFilename: 'HWA_0326.txt',
rows: [
{
line: 2,
buchungstext: 'Kaffee',
umsatz: '12.50',
sollHaben: 'S',
gegenkonto: 2010,
erloeskonto: 4000,
isNew: false,
},
{
line: 3,
buchungstext: 'Kakao',
umsatz: '3.00',
sollHaben: 'H',
gegenkonto: 2011,
erloeskonto: 4711,
isNew: true,
},
],
newAccounts: [{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }],
rowErrors: [],
};
const ACCOUNTS: HandelswareAccount[] = [
{ id: 'a1', name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 },
{ id: 'a2', name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 },
];
const xlsx = () => new File(['x'], 'HWA 0326 Test.xlsx');
async function upload(file = xlsx()) {
const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
const downloadButton = () =>
screen.getByRole('button', { name: 'Buchungsdatei herunterladen' }) as HTMLButtonElement;
beforeEach(() => {
mockUser('USER');
api.getHandelswareSettings.mockResolvedValue(CONFIGURED);
api.listAccounts.mockResolvedValue(ACCOUNTS);
});
afterEach(() => {
cleanup();
for (const m of [...Object.values(api), mockDownload, mockAuthStore]) m.mockReset();
});
describe('HandelswareDatevPage — Import', () => {
it('zeigt die Vorschau mit allen Spalten, "neu"-Markierung und Zusammenfassung', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
render(<HandelswareDatevPage />);
await upload();
const table = await screen.findByRole('table');
for (const col of ['Buchungstext', 'Umsatz', 'S/H', 'Gegenkonto', 'Datum', 'Erlöskonto']) {
expect(within(table).getByRole('columnheader', { name: col })).toBeTruthy();
}
const kakao = within(table).getByText('Kakao').closest('tr') as HTMLElement;
expect(within(kakao).getByText('neu')).toBeTruthy();
expect(within(kakao).getByText('H')).toBeTruthy();
const kaffee = within(table).getByText('Kaffee').closest('tr') as HTMLElement;
expect(within(kaffee).queryByText('neu')).toBeNull();
expect(screen.getByText('Ein neues Konto wird beim Herunterladen gespeichert')).toBeTruthy();
expect(screen.getByText('Summe Soll 12,50, Summe Haben 3,00')).toBeTruthy();
});
it('füllt das Buchungsdatum vor, die Datumsspalte folgt der Änderung, ungültig sperrt den Download', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
render(<HandelswareDatevPage />);
await upload();
const input = (await screen.findByLabelText('Buchungsdatum (TTMM)')) as HTMLInputElement;
expect(input.value).toBe('3103');
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.change(input, { target: { value: '1503' } });
expect(within(screen.getByRole('table')).getAllByText('1503')).toHaveLength(2);
fireEvent.change(input, { target: { value: '3102' } });
expect(screen.getByText(/Bitte das Datum als TTMM angeben/)).toBeTruthy();
expect(downloadButton().disabled).toBe(true);
});
it('Download sendet dieselbe Datei, das bearbeitete Datum und die neuen Konten, dann downloadBase64', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
api.exportHandelsware.mockResolvedValue({
filename: 'HWA_0326.txt',
content: 'QUJD',
mimeType: 'text/plain;charset=utf-8',
createdCount: 1,
});
render(<HandelswareDatevPage />);
const file = xlsx();
await upload(file);
const input = (await screen.findByLabelText('Buchungsdatum (TTMM)')) as HTMLInputElement;
fireEvent.change(input, { target: { value: '3003' } });
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.click(downloadButton());
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('HWA_0326.txt', 'QUJD', 'text/plain;charset=utf-8'),
);
expect(api.exportHandelsware).toHaveBeenCalledWith(file, '3003', PREVIEW.newAccounts);
expect(await screen.findByText(/Ein neues Konto wurde gespeichert/)).toBeTruthy();
// Vorschau wird nach dem Speichern neu geladen (Konten sind jetzt bekannt).
await waitFor(() => expect(api.previewHandelsware).toHaveBeenCalledTimes(2));
});
it('bei 409 accountsChanged: Serverhinweis und Knopf zum Neuladen der Vorschau', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
api.exportHandelsware.mockRejectedValue(
new HandelswareRequestError(
409,
'accountsChanged',
'Die Kontenliste wurde inzwischen geändert.',
),
);
render(<HandelswareDatevPage />);
await upload();
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.click(downloadButton());
expect(await screen.findByText(/Die Kontenliste wurde inzwischen geändert/)).toBeTruthy();
expect(mockDownload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Vorschau neu laden' }));
await waitFor(() => expect(api.previewHandelsware).toHaveBeenCalledTimes(2));
});
it('Administrator sieht bei fehlenden Einstellungen den Hinweis mit Weg zu den Einstellungen', async () => {
mockUser('ADMIN');
api.getHandelswareSettings.mockResolvedValue(EMPTY);
render(<HandelswareDatevPage />);
expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
const field = (await screen.findByLabelText('Standard-Erlöskonto')) as HTMLInputElement;
expect(field.value).toBe('');
expect((screen.getByLabelText('Startwert Gegenkonto') as HTMLInputElement).value).toBe('');
});
it('normaler Benutzer sieht "Ein Administrator muss zuerst …" und keinen Einstellungen-Reiter', async () => {
api.getHandelswareSettings.mockResolvedValue(EMPTY);
render(<HandelswareDatevPage />);
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('listet fehlerhafte Zeilen mit Zeilennummer und sperrt den Download', async () => {
api.previewHandelsware.mockResolvedValue({
...PREVIEW,
rowErrors: [{ line: 5, code: 'umsatzInvalid', message: 'x' }],
});
render(<HandelswareDatevPage />);
await upload();
expect(await screen.findByText(/Zeile 5: Der Umsatz ist keine gültige Zahl/)).toBeTruthy();
expect(downloadButton().disabled).toBe(true);
});
});
describe('HandelswareDatevPage — Konten', () => {
async function openAccounts() {
render(<HandelswareDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Konten' }));
await screen.findByText('Tee');
}
it('listet die Konten', async () => {
await openAccounts();
expect(screen.getByText('Kaffee')).toBeTruthy();
expect(screen.getByText('2005')).toBeTruthy();
});
it('legt ein Konto an (Erlöskonto aus den Einstellungen vorbelegt)', async () => {
api.createAccount.mockResolvedValue({
id: 'a3',
name: 'Saft',
gegenkonto: 2020,
erloeskonto: 4711,
});
await openAccounts();
fireEvent.change(screen.getAllByLabelText('Name')[0], { target: { value: 'Saft' } });
fireEvent.change(screen.getAllByLabelText('Gegenkonto')[0], { target: { value: '2020' } });
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
await waitFor(() =>
expect(api.createAccount).toHaveBeenCalledWith({
name: 'Saft',
gegenkonto: 2020,
erloeskonto: 4711,
}),
);
expect(await screen.findByText('Saft')).toBeTruthy();
});
it('bearbeitet ein Konto', async () => {
api.updateAccount.mockResolvedValue({
id: 'a2',
name: 'Tee',
gegenkonto: 2999,
erloeskonto: 4001,
});
await openAccounts();
const row = screen.getByText('Tee').closest('tr') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Bearbeiten' }));
const editRow = screen.getByDisplayValue('Tee').closest('tr') as HTMLElement;
fireEvent.change(within(editRow).getByLabelText('Gegenkonto'), { target: { value: '2999' } });
fireEvent.click(within(editRow).getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.updateAccount).toHaveBeenCalledWith('a2', {
name: 'Tee',
gegenkonto: 2999,
erloeskonto: 4001,
}),
);
expect(await screen.findByText('2999')).toBeTruthy();
});
it('löscht erst nach Rückfrage', async () => {
api.deleteAccount.mockResolvedValue({ deleted: true });
await openAccounts();
const row = screen.getByText('Tee').closest('tr') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Löschen' }));
expect(api.deleteAccount).not.toHaveBeenCalled();
expect(screen.getByText('Konto „Tee“ wirklich löschen?')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Ja, löschen' }));
await waitFor(() => expect(api.deleteAccount).toHaveBeenCalledWith('a2'));
await waitFor(() => expect(screen.queryByText('Tee')).toBeNull());
});
it('CSV-Import fragt vorher "Alle 2 vorhandenen Konten werden … ersetzt" und ruft erst nach Bestätigung', async () => {
api.importAccountsCsv.mockResolvedValue({ count: 5 });
await openAccounts();
const csv = new File(['a;1;2'], 'konten.csv');
fireEvent.change(screen.getByTestId('accounts-csv-input'), { target: { files: [csv] } });
expect(
screen.getByText(
/Alle 2 vorhandenen Konten werden durch den Inhalt der Datei „konten\.csv“ ersetzt/,
),
).toBeTruthy();
expect(api.importAccountsCsv).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Konten ersetzen' }));
await waitFor(() => expect(api.importAccountsCsv).toHaveBeenCalledWith(csv));
expect(await screen.findByText('5 Konten wurden importiert.')).toBeTruthy();
});
it('CSV-Import mit Fehlern zeigt die Zeilen', async () => {
api.importAccountsCsv.mockRejectedValue(
new HandelswareRequestError(400, 'csvErrors', 'x', [
{ line: 3, code: 'gegenkontoInvalid', message: 'y' },
]),
);
await openAccounts();
fireEvent.change(screen.getByTestId('accounts-csv-input'), {
target: { files: [new File(['x'], 'k.csv')] },
});
fireEvent.click(screen.getByRole('button', { name: 'Konten ersetzen' }));
expect(await screen.findByText(/Zeile 3: Das Gegenkonto muss eine ganze Zahl/)).toBeTruthy();
});
it('CSV-Export lädt die Datei herunter', async () => {
api.exportAccountsCsv.mockResolvedValue({
filename: 'Konten.csv',
content: 'QQ==',
mimeType: 'text/csv;charset=utf-8',
});
await openAccounts();
fireEvent.click(screen.getByRole('button', { name: 'CSV exportieren' }));
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('Konten.csv', 'QQ==', 'text/csv;charset=utf-8'),
);
});
});
describe('HandelswareDatevPage — Einstellungen', () => {
it('Reiter nur für Administratoren, Eingaben validiert, Speichern ruft die API', async () => {
mockUser('SUPER_ADMIN');
api.getHandelswareSettings.mockResolvedValue(EMPTY);
api.saveHandelswareSettings.mockResolvedValue(CONFIGURED);
render(<HandelswareDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
fireEvent.change(await screen.findByLabelText('Standard-Erlöskonto'), {
target: { value: 'abc' },
});
fireEvent.change(screen.getByLabelText('Startwert Gegenkonto'), { target: { value: '2000' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(
await screen.findByText('Bitte eine ganze Zahl von 1 bis 999999999 eingeben.'),
).toBeTruthy();
expect(api.saveHandelswareSettings).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Standard-Erlöskonto'), { target: { value: '4711' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.saveHandelswareSettings).toHaveBeenCalledWith({
erloeskonto: 4711,
startGegenkonto: 2000,
}),
);
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 HandelswareDatevLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="handelsware-datev">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,75 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { getHandelswareSettings, type HandelswareSettings } from '@/lib/handelsware-datev-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { AccountsTab } from './components/AccountsTab';
import { ImportTab } from './components/ImportTab';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'import' | 'accounts' | 'settings';
/**
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
* DATEV-Buchungsdatei herunterladen. Reiter Import / Konten / Einstellungen
* (letzterer nur fuer Administratoren). Nach einem Export zaehlt `accountsVersion`
* hoch, damit der Reiter "Konten" die neu gespeicherten Konten nachlaedt.
*/
export default function HandelswareDatevPage() {
const t = useTranslations('handelswareDatev');
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const [tab, setTab] = useState<TabId>('import');
const [settings, setSettings] = useState<HandelswareSettings | null>(null);
const [accountsVersion, setAccountsVersion] = useState(0);
useEffect(() => {
let cancelled = false;
getHandelswareSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch(() => {
// Ohne Einstellungen bleibt der Hinweis aus; die API sperrt trotzdem sauber.
});
return () => {
cancelled = true;
};
}, []);
const tabs: { id: TabId; label: string }[] = [
{ id: 'import', label: t('tabs.import') },
{ id: 'accounts', label: t('tabs.accounts') },
];
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !isAdmin ? 'import' : tab;
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="handelsware-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 === 'import' && (
<ImportTab
settings={settings}
isAdmin={isAdmin}
onOpenSettings={() => setTab('settings')}
onAccountsChanged={() => setAccountsVersion((v) => v + 1)}
/>
)}
{activeTab === 'accounts' && (
<AccountsTab settings={settings} reloadKey={accountsVersion} />
)}
{activeTab === 'settings' && <SettingsTab settings={settings} onSaved={setSettings} />}
</div>
</div>
);
}
@@ -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 HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout';
import TenderRadarLayout from './tender-radar/layout';
@@ -40,6 +41,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['domaincheck', DomaincheckLayout],
['tender-radar', TenderRadarLayout],
['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild });