From 42b89f110be2eb8910bc9e1beb761dff661cae12 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 11:47:09 +0200 Subject: [PATCH] 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) --- .../components/AccountsTab.tsx | 461 ++++++++++++++++++ .../components/ImportTab.tsx | 320 ++++++++++++ .../components/SettingsTab.tsx | 121 +++++ .../handelsware-datev.test.tsx | 378 ++++++++++++++ .../modules/handelsware-datev/layout.tsx | 6 + .../modules/handelsware-datev/page.tsx | 75 +++ .../(portal)/modules/module-layouts.test.tsx | 2 + .../src/components/modules/module-tile.tsx | 7 + apps/web/src/lib/handelsware-datev-api.ts | 143 ++++++ apps/web/src/lib/module-identity.ts | 3 +- apps/web/src/lib/module-loader.ts | 6 + apps/web/src/lib/stores/nav-store.ts | 1 + apps/web/src/messages/de.json | 101 ++++ apps/web/src/messages/en.json | 101 ++++ apps/web/src/messages/umlaut-dictionary.ts | 2 + 15 files changed, 1726 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx create mode 100644 apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx create mode 100644 apps/web/src/lib/handelsware-datev-api.ts diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx new file mode 100644 index 0000000..5edde47 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx @@ -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(null); + const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null); + const [importErrors, setImportErrors] = useState([]); + const [newDraft, setNewDraft] = useState(EMPTY_DRAFT); + const [editId, setEditId] = useState(null); + const [editDraft, setEditDraft] = useState(EMPTY_DRAFT); + const [deleteId, setDeleteId] = useState(null); + const [pendingImport, setPendingImport] = useState(null); + const [busy, setBusy] = useState(false); + const fileInputRef = useRef(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 ( +
+
+ { + const picked = e.target.files?.[0]; + if (picked) { + setPendingImport(picked); + setMessage(null); + setImportErrors([]); + } + e.target.value = ''; + }} + /> + + + {t('accounts.csvHint')} +
+ + {pendingImport && ( +
+

+ {t('accounts.importConfirm', { + count: accounts?.length ?? 0, + file: pendingImport.name, + })} +

+
+ + +
+
+ )} + + {message && ( +

+ {message.text} +

+ )} + {importErrors.length > 0 && ( +
    + {importErrors.map((e) => ( +
  • + {t('import.rowErrorLine', { line: e.line })}:{' '} + {t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message} +
  • + ))} +
+ )} + +
+ setNewDraft({ ...newDraft, name: e.target.value })} + className={INPUT_CLASS} + /> + setNewDraft({ ...newDraft, gegenkonto: e.target.value.trim() })} + className={INPUT_CLASS} + /> + setNewDraft({ ...newDraft, erloeskonto: e.target.value.trim() })} + className={INPUT_CLASS} + /> + +
+ + {accounts === null ? ( +

{t('loading')}

+ ) : accounts.length === 0 ? ( +

{t('accounts.empty')}

+ ) : ( +
+ + + + + + + + + + + {accounts.map((account) => + editId === account.id ? ( + + + + + + + ) : ( + + + + + + + ), + )} + +
{t('accounts.name')}{t('accounts.counter')}{t('accounts.revenue')}{t('accounts.actions')}
+ setEditDraft({ ...editDraft, name: e.target.value })} + className={INPUT_CLASS} + /> + + + setEditDraft({ ...editDraft, gegenkonto: e.target.value.trim() }) + } + className={INPUT_CLASS} + /> + + + setEditDraft({ ...editDraft, erloeskonto: e.target.value.trim() }) + } + className={INPUT_CLASS} + /> + + + +
{account.name}{account.gegenkonto}{account.erloeskonto} + {deleteId === account.id ? ( + + {t('accounts.deleteConfirm', { name: account.name })} + + + + ) : ( + + + + + )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx new file mode 100644 index 0000000..5f44111 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx @@ -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(null); + const [preview, setPreview] = useState(null); + const [buchungsdatum, setBuchungsdatum] = useState(''); + const [checking, setChecking] = useState(false); + const [downloading, setDownloading] = useState(false); + const [message, setMessage] = useState(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 ( +
+ {blockedBySettings && ( +
+

{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}

+ {isAdmin && ( + + )} +
+ )} + + + + {checking &&

{t('import.checking')}

} + + {preview && ( +
+
+
+ + setBuchungsdatum(e.target.value.trim())} + className={INPUT_CLASS} + /> + {!dateValid &&

{t('import.dateInvalid')}

} +

{t('import.dateHint')}

+
+
+
+
{t('import.headerText')}:
+
{preview.headerText || '—'}
+
+
+
{t('import.exportFilename')}:
+
{preview.exportFilename}
+
+
+
+ + {preview.rowErrors.length > 0 && ( +
+

+ {t('import.rowErrorsTitle')} +

+
    + {preview.rowErrors.map((e) => ( +
  • + {t('import.rowErrorLine', { line: e.line })}:{' '} + {t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message} +
  • + ))} +
+
+ )} + + {preview.rows.length === 0 ? ( +

{t('import.noRows')}

+ ) : ( + <> +

+ {preview.newAccounts.length > 0 + ? t('import.newSummary', { count: preview.newAccounts.length }) + : t('import.noNewAccounts')} +

+
+ + + + + + + + + + + + + {visibleRows.map((row) => ( + + + + + + + + + ))} + +
{t('import.columns.text')} + {t('import.columns.amount')} + {t('import.columns.sh')}{t('import.columns.counter')}{t('import.columns.date')}{t('import.columns.revenue')}
+ {row.buchungstext} + {row.isNew && ( + + {t('import.newBadge')} + + )} + {row.umsatz}{row.sollHaben}{row.gegenkonto}{buchungsdatum}{row.erloeskonto}
+
+ {preview.rows.length > MAX_VISIBLE_ROWS && ( +

+ {t('import.truncated', { shown: MAX_VISIBLE_ROWS, total: preview.rows.length })} +

+ )} +

+ {t('import.totals', { + debit: amountFormat.format(debit / 100), + credit: amountFormat.format(credit / 100), + })} +

+ + )} + + +
+ )} + + {message && ( +
+

+ {message.text} +

+ {message.code === 'accountsChanged' && file && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx new file mode 100644 index 0000000..1bdb32c --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx @@ -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 ( +
+

{t('settings.intro')}

+ {fields.map((f) => { + const showError = touched && toNumber(f.value) === null; + return ( +
+ + f.set(e.target.value.trim())} + className={INPUT_CLASS} + /> +

{f.help}

+ {showError &&

{t('settings.numberInvalid')}

} +
+ ); + })} + + {message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx new file mode 100644 index 0000000..8586236 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx @@ -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( + + {ui} + , + ); +} + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx new file mode 100644 index 0000000..0ace449 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx @@ -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 {children}; +} diff --git a/apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx b/apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx new file mode 100644 index 0000000..7d6b62c --- /dev/null +++ b/apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx @@ -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('import'); + const [settings, setSettings] = useState(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 ( +
+ + +
+ {activeTab === 'import' && ( + setTab('settings')} + onAccountsChanged={() => setAccountsVersion((v) => v + 1)} + /> + )} + {activeTab === 'accounts' && ( + + )} + {activeTab === 'settings' && } +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx index dc1ed30..e36604f 100644 --- a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx +++ b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx @@ -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 }); diff --git a/apps/web/src/components/modules/module-tile.tsx b/apps/web/src/components/modules/module-tile.tsx index f05b7a5..3749958 100644 --- a/apps/web/src/components/modules/module-tile.tsx +++ b/apps/web/src/components/modules/module-tile.tsx @@ -50,6 +50,13 @@ const GLYPHS: Record = { ), + 'shopping-bag': ( + <> + + + + + ), tile: ( <> diff --git a/apps/web/src/lib/handelsware-datev-api.ts b/apps/web/src/lib/handelsware-datev-api.ts new file mode 100644 index 0000000..5656f13 --- /dev/null +++ b/apps/web/src/lib/handelsware-datev-api.ts @@ -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 { + return accountingRequest(`${BASE}/settings`); +} + +export function saveHandelswareSettings( + input: HandelswareSettingsInput, +): Promise { + return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input }); +} + +export function previewHandelsware(file: File): Promise { + return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) }); +} + +export function exportHandelsware( + file: File, + buchungsdatum: string, + newAccounts: NewAccount[], +): Promise { + 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 { + return accountingRequest(`${BASE}/accounts`); +} + +export function createAccount(input: HandelswareAccountInput): Promise { + return accountingRequest(`${BASE}/accounts`, { method: 'POST', json: input }); +} + +export function updateAccount( + id: string, + input: HandelswareAccountInput, +): Promise { + 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 { + return accountingRequest(`${BASE}/accounts/export-csv`); +} diff --git a/apps/web/src/lib/module-identity.ts b/apps/web/src/lib/module-identity.ts index 924b9ca..bb02e1c 100644 --- a/apps/web/src/lib/module-identity.ts +++ b/apps/web/src/lib/module-identity.ts @@ -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 = { 'tender-radar': 'radar', @@ -16,6 +16,7 @@ const ICONS: Record = { domaincheck: 'globe', proxmox: 'server', 'kantine-datev': 'utensils', + 'handelsware-datev': 'shopping-bag', }; /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts index 060b383..2422ea8 100644 --- a/apps/web/src/lib/module-loader.ts +++ b/apps/web/src/lib/module-loader.ts @@ -65,6 +65,12 @@ export const MODULE_REGISTRY: Record = { { ssr: false }, ), }, + 'handelsware-datev': { + component: dynamic( + () => import('@/app/(portal)/modules/handelsware-datev/page'), + { ssr: false }, + ), + }, }; /** diff --git a/apps/web/src/lib/stores/nav-store.ts b/apps/web/src/lib/stores/nav-store.ts index fc1861b..36f756d 100644 --- a/apps/web/src/lib/stores/nav-store.ts +++ b/apps/web/src/lib/stores/nav-store.ts @@ -30,6 +30,7 @@ const MODULE_TITLE_KEYS: Record = { 'dkv-fleet': 'dkvFleet.pageTitle', 'tender-radar': 'tenderRadar.page.title', 'kantine-datev': 'kantineDatev.title', + 'handelsware-datev': 'handelswareDatev.title', }; /** diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index bf9890e..ff1b8d7 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -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", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index bb73a47..9fef950 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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", diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 3bbddd0..5999d0e 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -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',