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 CertManagerLayout from './cert-manager/layout';
import DkvFleetLayout from './dkv-fleet/layout'; import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout'; import DomaincheckLayout from './domaincheck/layout';
import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout'; import KantineDatevLayout from './kantine-datev/layout';
import TenderRadarLayout from './tender-radar/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], ['domaincheck', DomaincheckLayout],
['tender-radar', TenderRadarLayout], ['tender-radar', TenderRadarLayout],
['kantine-datev', KantineDatevLayout], ['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild }); const element = Layout({ children: placeholderChild });
@@ -50,6 +50,13 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" /> <path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
</> </>
), ),
'shopping-bag': (
<>
<path d="M16 10a4 4 0 0 1-8 0" />
<path d="M3.103 6.034h17.794" />
<path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z" />
</>
),
tile: ( tile: (
<> <>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
+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`). * (`--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> = { const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar', 'tender-radar': 'radar',
@@ -16,6 +16,7 @@ const ICONS: Record<string, ModuleIconId> = {
domaincheck: 'globe', domaincheck: 'globe',
proxmox: 'server', proxmox: 'server',
'kantine-datev': 'utensils', 'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
}; };
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ /** 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 }, { 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', 'dkv-fleet': 'dkvFleet.pageTitle',
'tender-radar': 'tenderRadar.page.title', 'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title', 'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title',
}; };
/** /**
+101
View File
@@ -1698,6 +1698,107 @@
"saved": "Die Einstellungen wurden gespeichert." "saved": "Die Einstellungen wurden gespeichert."
} }
}, },
"handelswareDatev": {
"title": "Handelsware",
"description": "Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei herunterladen.",
"tabs": {
"import": "Import",
"accounts": "Konten",
"settings": "Einstellungen"
},
"loading": "Wird geladen …",
"notConfigured": {
"admin": "Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt. Ohne diese Angaben können keine Konten zugeordnet werden.",
"adminAction": "Zu den Einstellungen",
"user": "Ein Administrator muss zuerst Standard-Erlöskonto und Startwert Gegenkonto hinterlegen."
},
"import": {
"dropPlaceholder": "Excel-Datei hierher ziehen oder klicken",
"dropFormats": ".xlsx, Buchungstext in Spalte A, Umsatz in Spalte B, Kopftext in Zelle B1",
"clear": "Datei entfernen",
"checking": "Datei wird geprüft …",
"headerText": "Kopftext",
"exportFilename": "Dateiname des Downloads",
"date": "Buchungsdatum (TTMM)",
"dateHint": "Wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und kann geändert werden.",
"dateInvalid": "Bitte das Datum als TTMM angeben, zum Beispiel 3103 für den 31.03.",
"columns": {
"text": "Buchungstext",
"amount": "Umsatz",
"sh": "S/H",
"counter": "Gegenkonto",
"date": "Datum",
"revenue": "Erlöskonto"
},
"newBadge": "neu",
"newSummary": "{count, plural, one {Ein neues Konto wird beim Herunterladen gespeichert} other {# neue Konten werden beim Herunterladen gespeichert}}",
"noNewAccounts": "Alle Produkte haben bereits ein Konto.",
"totals": "Summe Soll {debit}, Summe Haben {credit}",
"truncated": "Angezeigt werden die ersten {shown} von {total} Zeilen. Die Datei enthält alle Zeilen.",
"noRows": "Die Datei enthält keine Datenzeilen.",
"rowErrorsTitle": "Fehlerhafte Zeilen",
"rowErrorLine": "Zeile {line}",
"download": "Buchungsdatei herunterladen",
"downloading": "Datei wird erstellt …",
"downloaded": "Die Datei {filename} wurde heruntergeladen. {count, plural, =0 {Es wurden keine neuen Konten gespeichert.} one {Ein neues Konto wurde gespeichert.} other {# neue Konten wurden gespeichert.}}",
"reloadPreview": "Vorschau neu laden"
},
"accounts": {
"name": "Name",
"counter": "Gegenkonto",
"revenue": "Erlöskonto",
"actions": "Aktionen",
"empty": "Es sind noch keine Konten vorhanden.",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"deleteConfirm": "Konto „{name}“ wirklich löschen?",
"deleteYes": "Ja, löschen",
"namePlaceholder": "Produktname",
"importCsv": "CSV importieren",
"exportCsv": "CSV exportieren",
"importConfirm": "{count, plural, =0 {Die leere Kontenliste wird} one {Das vorhandene Konto wird} other {Alle # vorhandenen Konten werden}} durch den Inhalt der Datei „{file}“ ersetzt. Fortfahren?",
"importReplace": "Konten ersetzen",
"importDone": "{count, plural, one {Ein Konto wurde importiert.} other {# Konten wurden importiert.}}",
"importErrors": "Die Datei enthält Fehler, es wurde nichts geändert:",
"csvHint": "CSV-Format: Name;Gegenkonto;Konto (Semikolon, UTF-8 oder Windows-1252).",
"invalidNumber": "Gegenkonto und Erlöskonto müssen ganze Zahlen sein.",
"nameRequired": "Bitte einen Namen angeben."
},
"settings": {
"intro": "Diese Angaben gelten für alle Benutzer Ihres Mandanten und werden nur einmal hinterlegt.",
"erloeskonto": "Standard-Erlöskonto",
"erloeskontoHelp": "Wird neuen Konten zugeordnet.",
"startGegenkonto": "Startwert Gegenkonto",
"startGegenkontoHelp": "Erste Nummer, die vergeben wird, wenn die Kontenliste leer ist.",
"numberInvalid": "Bitte eine ganze Zahl von 1 bis 999999999 eingeben.",
"save": "Speichern",
"saving": "Wird gespeichert …",
"saved": "Die Einstellungen wurden gespeichert."
},
"errors": {
"settingsMissing": "Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.",
"invalidFile": "Die Datei ist keine gültige Excel-Datei.",
"tooManyRows": "Die Datei enthält zu viele Datenzeilen (höchstens 10000).",
"rowErrors": "Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.",
"noRows": "Die Datei enthält keine Datenzeilen.",
"buchungsdatumInvalid": "Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.",
"accountsChanged": "Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.",
"nameTaken": "Ein Konto mit diesem Namen gibt es bereits.",
"umsatzInvalid": "Der Umsatz ist keine gültige Zahl.",
"csvErrors": "Die CSV-Datei enthält Fehler. Es wurde nichts geändert.",
"newAccountsInvalid": "Die Angaben zu den neuen Konten sind ungültig.",
"nameEmpty": "Der Name fehlt.",
"nameTooLong": "Der Name ist zu lang (höchstens 120 Zeichen).",
"gegenkontoInvalid": "Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.",
"erloeskontoInvalid": "Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.",
"missingErloeskonto": "Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.",
"duplicateName": "Der Name kommt in der Datei mehrfach vor.",
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
}
},
"moduleCategories": { "moduleCategories": {
"domain-tools": "Domains", "domain-tools": "Domains",
"security-tools": "Sicherheit", "security-tools": "Sicherheit",
+101
View File
@@ -1698,6 +1698,107 @@
"saved": "The settings have been saved." "saved": "The settings have been saved."
} }
}, },
"handelswareDatev": {
"title": "Merchandise",
"description": "Map merchandise sales from Excel to revenue accounts and download them as a DATEV booking file.",
"tabs": {
"import": "Import",
"accounts": "Accounts",
"settings": "Settings"
},
"loading": "Loading …",
"notConfigured": {
"admin": "The default revenue account and the starting counter account have not been set yet. Without them no accounts can be assigned.",
"adminAction": "Go to settings",
"user": "An administrator has to set the default revenue account and the starting counter account first."
},
"import": {
"dropPlaceholder": "Drag an Excel file here or click",
"dropFormats": ".xlsx, booking text in column A, amount in column B, header text in cell B1",
"clear": "Remove file",
"checking": "Checking file …",
"headerText": "Header text",
"exportFilename": "Download file name",
"date": "Booking date (DDMM)",
"dateHint": "Derived from the file name (last day of the month) and can be changed.",
"dateInvalid": "Please enter the date as DDMM, for example 3103 for 31 March.",
"columns": {
"text": "Booking text",
"amount": "Amount",
"sh": "D/C",
"counter": "Counter account",
"date": "Date",
"revenue": "Revenue account"
},
"newBadge": "new",
"newSummary": "{count, plural, one {One new account will be saved when you download} other {# new accounts will be saved when you download}}",
"noNewAccounts": "All products already have an account.",
"totals": "Total debit {debit}, total credit {credit}",
"truncated": "Showing the first {shown} of {total} rows. The file contains all rows.",
"noRows": "The file contains no data rows.",
"rowErrorsTitle": "Faulty rows",
"rowErrorLine": "Row {line}",
"download": "Download booking file",
"downloading": "Creating file …",
"downloaded": "The file {filename} has been downloaded. {count, plural, =0 {No new accounts were saved.} one {One new account was saved.} other {# new accounts were saved.}}",
"reloadPreview": "Reload preview"
},
"accounts": {
"name": "Name",
"counter": "Counter account",
"revenue": "Revenue account",
"actions": "Actions",
"empty": "There are no accounts yet.",
"add": "Add",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirm": "Really delete account “{name}”?",
"deleteYes": "Yes, delete",
"namePlaceholder": "Product name",
"importCsv": "Import CSV",
"exportCsv": "Export CSV",
"importConfirm": "{count, plural, =0 {The empty account list is} one {The existing account is} other {All # existing accounts are}} replaced by the content of the file “{file}”. Continue?",
"importReplace": "Replace accounts",
"importDone": "{count, plural, one {One account was imported.} other {# accounts were imported.}}",
"importErrors": "The file contains errors, nothing was changed:",
"csvHint": "CSV format: Name;Counter account;Account (semicolon, UTF-8 or Windows-1252).",
"invalidNumber": "Counter account and revenue account must be whole numbers.",
"nameRequired": "Please enter a name."
},
"settings": {
"intro": "These values apply to all users of your tenant and only have to be set once.",
"erloeskonto": "Default revenue account",
"erloeskontoHelp": "Assigned to new accounts.",
"startGegenkonto": "Starting counter account",
"startGegenkontoHelp": "First number handed out when the account list is empty.",
"numberInvalid": "Please enter a whole number from 1 to 999999999.",
"save": "Save",
"saving": "Saving …",
"saved": "The settings have been saved."
},
"errors": {
"settingsMissing": "The default revenue account and the starting counter account have not been set yet.",
"invalidFile": "The file is not a valid Excel file.",
"tooManyRows": "The file contains too many data rows (10000 at most).",
"rowErrors": "The file contains faulty rows and cannot be exported.",
"noRows": "The file contains no data rows.",
"buchungsdatumInvalid": "The booking date must be given as DDMM, for example 3103.",
"accountsChanged": "The account list has changed in the meantime. Please upload the file again to refresh the preview.",
"nameTaken": "An account with this name already exists.",
"umsatzInvalid": "The amount is not a valid number.",
"csvErrors": "The CSV file contains errors. Nothing was changed.",
"newAccountsInvalid": "The details of the new accounts are invalid.",
"nameEmpty": "The name is missing.",
"nameTooLong": "The name is too long (120 characters at most).",
"gegenkontoInvalid": "The counter account must be a whole number from 1 to 999999999.",
"erloeskontoInvalid": "The revenue account must be a whole number from 1 to 999999999.",
"missingErloeskonto": "The revenue account is missing and no default revenue account is set.",
"duplicateName": "The name appears more than once in the file.",
"request": "The request failed. Please try again."
}
},
"moduleCategories": { "moduleCategories": {
"domain-tools": "Domains", "domain-tools": "Domains",
"security-tools": "Security", "security-tools": "Security",
@@ -146,6 +146,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Bildausschnitt', 'Bildausschnitt',
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen // XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
'neuem', 'neuem',
// quick-261002-fm5: Handelsware („Ein neues Konto wird gespeichert“), korrektes Deutsch
'neues',
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“ // XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
'Ausschnitt', 'Ausschnitt',
'Absenderadresse', 'Absenderadresse',