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 });
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
),
|
||||
'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: (
|
||||
<>
|
||||
<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" />
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
|
||||
*/
|
||||
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'tile';
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'tile';
|
||||
|
||||
const ICONS: Record<string, ModuleIconId> = {
|
||||
'tender-radar': 'radar',
|
||||
@@ -16,6 +16,7 @@ const ICONS: Record<string, ModuleIconId> = {
|
||||
domaincheck: 'globe',
|
||||
proxmox: 'server',
|
||||
'kantine-datev': 'utensils',
|
||||
'handelsware-datev': 'shopping-bag',
|
||||
};
|
||||
|
||||
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
|
||||
|
||||
@@ -65,6 +65,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
'handelsware-datev': {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/handelsware-datev/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
|
||||
'dkv-fleet': 'dkvFleet.pageTitle',
|
||||
'tender-radar': 'tenderRadar.page.title',
|
||||
'kantine-datev': 'kantineDatev.title',
|
||||
'handelsware-datev': 'handelswareDatev.title',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1698,6 +1698,107 @@
|
||||
"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": {
|
||||
"domain-tools": "Domains",
|
||||
"security-tools": "Sicherheit",
|
||||
|
||||
@@ -1698,6 +1698,107 @@
|
||||
"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": {
|
||||
"domain-tools": "Domains",
|
||||
"security-tools": "Security",
|
||||
|
||||
@@ -146,6 +146,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Bildausschnitt',
|
||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||
'neuem',
|
||||
// quick-261002-fm5: Handelsware („Ein neues Konto wird gespeichert“), korrektes Deutsch
|
||||
'neues',
|
||||
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
|
||||
'Ausschnitt',
|
||||
'Absenderadresse',
|
||||
|
||||
Reference in New Issue
Block a user