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