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:
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user