feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung
- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting) - CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export - Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer - hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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 de from '@/messages/de.json';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockGetSettings = vi.fn();
|
||||
const mockSaveSettings = vi.fn();
|
||||
const mockPreview = vi.fn();
|
||||
const mockExport = vi.fn();
|
||||
|
||||
vi.mock('@/lib/kantine-datev-api', () => ({
|
||||
getKantineSettings: (...a: unknown[]) => mockGetSettings(...a),
|
||||
saveKantineSettings: (...a: unknown[]) => mockSaveSettings(...a),
|
||||
previewKantineCsv: (...a: unknown[]) => mockPreview(...a),
|
||||
exportKantineCsv: (...a: unknown[]) => mockExport(...a),
|
||||
KantineRequestError: class extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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 KantineDatevPage from './page';
|
||||
|
||||
const CONFIGURED = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111', configured: true };
|
||||
const EMPTY = { beraterNr: null, mandantNr: null, lohnart: null, configured: false };
|
||||
|
||||
const GOOD_PREVIEW = {
|
||||
rowCount: 3,
|
||||
abrechnungsMonat: '03/2026',
|
||||
totalCents: 12345,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
canExport: true,
|
||||
blockedReason: null,
|
||||
};
|
||||
|
||||
function csvFile() {
|
||||
return new File(['x'], 'kantine.csv', { type: 'text/csv' });
|
||||
}
|
||||
|
||||
async function upload(file = csvFile()) {
|
||||
const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser('USER');
|
||||
mockGetSettings.mockResolvedValue(CONFIGURED);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
for (const m of [
|
||||
mockGetSettings,
|
||||
mockSaveSettings,
|
||||
mockPreview,
|
||||
mockExport,
|
||||
mockDownload,
|
||||
mockAuthStore,
|
||||
]) {
|
||||
m.mockReset();
|
||||
}
|
||||
});
|
||||
|
||||
describe('KantineDatevPage — nicht eingerichtet', () => {
|
||||
it('Administrator sieht Hinweis mit Weg zu den Einstellungen und das Formular', async () => {
|
||||
mockUser('ADMIN');
|
||||
mockGetSettings.mockResolvedValue(EMPTY);
|
||||
render(<KantineDatevPage />);
|
||||
expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
|
||||
const berater = (await screen.findByLabelText('Beraternummer')) as HTMLInputElement;
|
||||
expect(berater.value).toBe('');
|
||||
expect(screen.getByLabelText('Mandantennummer')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Lohnart')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => {
|
||||
mockGetSettings.mockResolvedValue(EMPTY);
|
||||
render(<KantineDatevPage />);
|
||||
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevPage — Abrechnung', () => {
|
||||
it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => {
|
||||
mockPreview.mockResolvedValue(GOOD_PREVIEW);
|
||||
render(<KantineDatevPage />);
|
||||
await upload();
|
||||
expect(await screen.findByText('03/2026')).toBeTruthy();
|
||||
expect(screen.getByText('3')).toBeTruthy();
|
||||
expect(screen.getByText(/123,45/)).toBeTruthy();
|
||||
expect(screen.getByText('Die hochgeladenen Daten werden nicht gespeichert.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('listet Warnungen und Fehler mit Zeilennummer, Download gesperrt', async () => {
|
||||
mockPreview.mockResolvedValue({
|
||||
...GOOD_PREVIEW,
|
||||
canExport: false,
|
||||
blockedReason: 'errors',
|
||||
warnings: [
|
||||
{
|
||||
code: 'multipleMonths',
|
||||
message: 'x',
|
||||
params: { months: ['03/2026', '04/2026'] },
|
||||
},
|
||||
],
|
||||
errors: [{ row: 7, field: 'betrag', code: 'betragFormat', message: 'Betrag muss ...' }],
|
||||
});
|
||||
render(<KantineDatevPage />);
|
||||
await upload();
|
||||
const table = await screen.findByRole('table');
|
||||
const row = within(table).getByText('7').closest('tr') as HTMLElement;
|
||||
expect(within(row).getByText('Betrag')).toBeTruthy();
|
||||
expect(within(row).getByText(/deutschen Zahlenformat/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Verschiedene Abrechnungsmonate erkannt: 03\/2026, 04\/2026/),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'DATEV-Datei herunterladen' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('sperrt den Download, solange nichts hinterlegt ist', async () => {
|
||||
mockGetSettings.mockResolvedValue(EMPTY);
|
||||
mockPreview.mockResolvedValue({
|
||||
...GOOD_PREVIEW,
|
||||
canExport: false,
|
||||
blockedReason: 'settingsMissing',
|
||||
});
|
||||
render(<KantineDatevPage />);
|
||||
await upload();
|
||||
const button = (await screen.findByRole('button', {
|
||||
name: 'DATEV-Datei herunterladen',
|
||||
})) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick auf Download ruft den Export mit derselben Datei und danach downloadBase64', async () => {
|
||||
mockPreview.mockResolvedValue(GOOD_PREVIEW);
|
||||
mockExport.mockResolvedValue({
|
||||
filename: 'LuG_1_2_03_2026.sic',
|
||||
content: 'QUJD',
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
render(<KantineDatevPage />);
|
||||
const file = csvFile();
|
||||
await upload(file);
|
||||
const button = (await screen.findByRole('button', {
|
||||
name: 'DATEV-Datei herunterladen',
|
||||
})) as HTMLButtonElement;
|
||||
await waitFor(() => expect(button.disabled).toBe(false));
|
||||
fireEvent.click(button);
|
||||
await waitFor(() =>
|
||||
expect(mockDownload).toHaveBeenCalledWith('LuG_1_2_03_2026.sic', 'QUJD', 'text/plain'),
|
||||
);
|
||||
expect(mockExport).toHaveBeenCalledWith(file);
|
||||
expect(await screen.findByText(/LuG_1_2_03_2026\.sic/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KantineDatevPage — Einstellungen speichern', () => {
|
||||
it('lehnt Buchstaben vor dem Senden ab und speichert Ziffern', async () => {
|
||||
mockUser('ADMIN');
|
||||
mockGetSettings.mockResolvedValue(EMPTY);
|
||||
mockSaveSettings.mockResolvedValue(CONFIGURED);
|
||||
render(<KantineDatevPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
|
||||
fireEvent.change(await screen.findByLabelText('Beraternummer'), { target: { value: '12a' } });
|
||||
fireEvent.change(screen.getByLabelText('Mandantennummer'), { target: { value: '12345' } });
|
||||
fireEvent.change(screen.getByLabelText('Lohnart'), { target: { value: '1111' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByText('Bitte nur Ziffern eingeben (1 bis 10 Stellen).')).toBeTruthy();
|
||||
expect(mockSaveSettings).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Beraternummer'), { target: { value: '1234567' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() =>
|
||||
expect(mockSaveSettings).toHaveBeenCalledWith({
|
||||
beraterNr: '1234567',
|
||||
mandantNr: '12345',
|
||||
lohnart: '1111',
|
||||
}),
|
||||
);
|
||||
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 KantineDatevLayout({ children }: { children: ReactNode }) {
|
||||
return <ModuleAccessGate moduleSlug="kantine-datev">{children}</ModuleAccessGate>;
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FileDropArea } from '@/components/accounting/file-drop-area';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { downloadBase64 } from '@/lib/download-base64';
|
||||
import {
|
||||
exportKantineCsv,
|
||||
getKantineSettings,
|
||||
type KantinePreview,
|
||||
KantineRequestError,
|
||||
type KantineSettings,
|
||||
previewKantineCsv,
|
||||
saveKantineSettings,
|
||||
} from '@/lib/kantine-datev-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
type TabId = 'billing' | 'settings';
|
||||
|
||||
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';
|
||||
|
||||
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
/**
|
||||
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV hochladen, pruefen und
|
||||
* als DATEV-Lohndatei herunterladen. Die Datei bleibt im Browser (State) und
|
||||
* wird fuer Vorschau UND Export an die API geschickt — die API speichert nichts.
|
||||
*/
|
||||
export default function KantineDatevPage() {
|
||||
const t = useTranslations('kantineDatev');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
|
||||
const [tab, setTab] = useState<TabId>('billing');
|
||||
const [settings, setSettings] = useState<KantineSettings | null>(null);
|
||||
const [settingsError, setSettingsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getKantineSettings()
|
||||
.then((s) => {
|
||||
if (!cancelled) setSettings(s);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSettingsError(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }];
|
||||
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tab === 'settings' && !isAdmin ? 'billing' : tab;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||
<PageHeader moduleSlug="kantine-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 === 'billing' ? (
|
||||
<BillingTab
|
||||
settings={settings}
|
||||
settingsError={settingsError}
|
||||
isAdmin={isAdmin}
|
||||
onOpenSettings={() => setTab('settings')}
|
||||
/>
|
||||
) : (
|
||||
<SettingsTab settings={settings} onSaved={setSettings} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingTab({
|
||||
settings,
|
||||
settingsError,
|
||||
isAdmin,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
settings: KantineSettings | null;
|
||||
settingsError: boolean;
|
||||
isAdmin: boolean;
|
||||
onOpenSettings: () => void;
|
||||
}) {
|
||||
const t = useTranslations('kantineDatev');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<KantinePreview | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
|
||||
|
||||
const requestError = useCallback(
|
||||
(error: unknown): string => {
|
||||
if (error instanceof KantineRequestError) {
|
||||
if (error.code && t.has(`errors.${error.code}`)) return t(`errors.${error.code}`);
|
||||
return error.message;
|
||||
}
|
||||
return t('errors.request');
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFile = async (selected: File) => {
|
||||
setFile(selected);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
setChecking(true);
|
||||
try {
|
||||
setPreview(await previewKantineCsv(selected));
|
||||
} catch (error) {
|
||||
setMessage({ kind: 'error', text: requestError(error) });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!file) return;
|
||||
setDownloading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await exportKantineCsv(file);
|
||||
downloadBase64(result.filename, result.content, result.mimeType);
|
||||
setMessage({ kind: 'ok', text: t('download.done', { filename: result.filename }) });
|
||||
} catch (error) {
|
||||
setMessage({ kind: 'error', text: requestError(error) });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const notConfigured = settings !== null && !settings.configured;
|
||||
const canDownload = Boolean(preview?.canExport) && !downloading && !checking;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{settingsError && <p className="text-sm text-destructive">{t('errors.request')}</p>}
|
||||
|
||||
{notConfigured && (
|
||||
<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=".csv,text/csv"
|
||||
file={file}
|
||||
onFile={handleFile}
|
||||
onClear={handleClear}
|
||||
placeholder={t('dropZone.placeholder')}
|
||||
hint={t('dropZone.formats')}
|
||||
clearLabel={t('dropZone.clear')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('noStorage')}</p>
|
||||
|
||||
{checking && <p className="text-sm text-muted-foreground">{t('checking')}</p>}
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-5">
|
||||
<section aria-label={t('summary.title')}>
|
||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryItem label={t('summary.rows')} value={String(preview.rowCount)} />
|
||||
<SummaryItem
|
||||
label={t('summary.month')}
|
||||
value={preview.abrechnungsMonat ?? t('summary.noMonth')}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('summary.total')}
|
||||
value={euro.format(preview.totalCents / 100)}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('warnings.title')}</h2>
|
||||
<ul className="space-y-1 text-sm text-status-warn-fg">
|
||||
{preview.warnings.map((w) => (
|
||||
<li key={`${w.code}-${w.message}`}>
|
||||
{w.code === 'multipleMonths' && w.params?.months
|
||||
? t('warnings.multipleMonths', { months: w.params.months.join(', ') })
|
||||
: w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{preview.errors.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-destructive">{t('errors.title')}</h2>
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.line')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.field')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('errors.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.errors.map((e) => (
|
||||
<tr key={`${e.row}-${e.field}-${e.code}`} className="border-t border-border">
|
||||
<td className="px-3 py-2 tabular-nums">{e.row}</td>
|
||||
<td className="px-3 py-2">
|
||||
{t.has(`fields.${e.field}`) ? t(`fields.${e.field}`) : e.field}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={BUTTON_CLASS}
|
||||
disabled={!canDownload}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{downloading ? t('download.busy') : t('download.button')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p
|
||||
role={message.kind === 'error' ? 'alert' : 'status'}
|
||||
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border px-4 py-3">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTab({
|
||||
settings,
|
||||
onSaved,
|
||||
}: {
|
||||
settings: KantineSettings | null;
|
||||
onSaved: (s: KantineSettings) => void;
|
||||
}) {
|
||||
const t = useTranslations('kantineDatev');
|
||||
const [beraterNr, setBeraterNr] = useState('');
|
||||
const [mandantNr, setMandantNr] = useState('');
|
||||
const [lohnart, setLohnart] = useState('');
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
|
||||
|
||||
// Gespeicherte Werte einmal ins Formular uebernehmen, sobald sie geladen sind.
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setBeraterNr(settings.beraterNr ?? '');
|
||||
setMandantNr(settings.mandantNr ?? '');
|
||||
setLohnart(settings.lohnart ?? '');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const valid = (v: string) => /^\d{1,10}$/.test(v);
|
||||
const allValid = valid(beraterNr) && valid(mandantNr) && valid(lohnart);
|
||||
|
||||
const handleSave = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setTouched(true);
|
||||
if (!allValid) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const saved = await saveKantineSettings({ beraterNr, mandantNr, lohnart });
|
||||
onSaved(saved);
|
||||
setMessage({ kind: 'ok', text: t('settings.saved') });
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
kind: 'error',
|
||||
text: error instanceof KantineRequestError ? error.message : t('errors.request'),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{ id: 'beraterNr', label: t('settings.beraterNr'), value: beraterNr, set: setBeraterNr },
|
||||
{ id: 'mandantNr', label: t('settings.mandantNr'), value: mandantNr, set: setMandantNr },
|
||||
{ id: 'lohnart', label: t('settings.lohnart'), value: lohnart, set: setLohnart },
|
||||
];
|
||||
|
||||
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 && !valid(f.value);
|
||||
return (
|
||||
<div key={f.id} className="space-y-1">
|
||||
<label htmlFor={`kantine-${f.id}`} className="text-sm font-medium text-foreground">
|
||||
{f.label}
|
||||
</label>
|
||||
<input
|
||||
id={`kantine-${f.id}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
maxLength={10}
|
||||
value={f.value}
|
||||
aria-invalid={showError}
|
||||
onChange={(e) => f.set(e.target.value.trim())}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{showError && <p className="text-xs text-destructive">{t('settings.digitsOnly')}</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>
|
||||
);
|
||||
}
|
||||
@@ -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 KantineDatevLayout from './kantine-datev/layout';
|
||||
import TenderRadarLayout from './tender-radar/layout';
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
|
||||
['dkv-fleet', DkvFleetLayout],
|
||||
['domaincheck', DomaincheckLayout],
|
||||
['tender-radar', TenderRadarLayout],
|
||||
['kantine-datev', KantineDatevLayout],
|
||||
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
|
||||
const element = Layout({ children: placeholderChild });
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface FileDropAreaProps {
|
||||
onFile: (file: File) => void;
|
||||
onClear: () => void;
|
||||
accept: string;
|
||||
file: File | null;
|
||||
/** Text in der leeren Flaeche. */
|
||||
placeholder: string;
|
||||
/** Zweite Zeile (erlaubte Formate). */
|
||||
hint?: string;
|
||||
/** Beschriftung des Entfernen-Knopfs (fuer Screenreader). */
|
||||
clearLabel: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ablageflaeche fuer eine einzelne Datei (Finanzbuchhaltungs-Module,
|
||||
* quick-261002-fm5). Eigenstaendig, ohne Texte aus einem anderen Modul —
|
||||
* Bauform wie die Ablage des Zertifikat-Managers: echte Schaltflaeche fuer
|
||||
* Klick UND Ziehen, Entfernen als Geschwister daneben.
|
||||
*/
|
||||
export function FileDropArea({
|
||||
onFile,
|
||||
onClear,
|
||||
accept,
|
||||
file,
|
||||
placeholder,
|
||||
hint,
|
||||
clearLabel,
|
||||
disabled = false,
|
||||
}: FileDropAreaProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
data-testid="file-drop-input"
|
||||
onChange={(e) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (picked) onFile(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped && !disabled) onFile(dropped);
|
||||
}}
|
||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
<span className="text-sm text-foreground">
|
||||
{file.name}{' '}
|
||||
<span className="text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="block space-y-1">
|
||||
<span className="block text-sm text-muted-foreground">{placeholder}</span>
|
||||
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{file && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={clearLabel}
|
||||
onClick={onClear}
|
||||
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
interface TabBarProps<T extends string> {
|
||||
tabs: { id: T; label: string }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
}
|
||||
|
||||
/** Reiterleiste der Finanzbuchhaltungs-Module — Muster Zertifikat-Manager. */
|
||||
export function TabBar<T extends string>({ tabs, active, onChange }: TabBarProps<T>) {
|
||||
return (
|
||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
aria-current={active === tab.id ? 'page' : undefined}
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
active === tab.id
|
||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,13 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</>
|
||||
),
|
||||
utensils: (
|
||||
<>
|
||||
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
|
||||
<path d="M7 2v20" />
|
||||
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
|
||||
</>
|
||||
),
|
||||
tile: (
|
||||
<>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Gemeinsame Anfragehilfen der Finanzbuchhaltungs-Module (Kantinenabrechnung,
|
||||
* Handelsware; quick-261002-fm5). `credentials: 'include'` fuer Cookie-Auth,
|
||||
* `NEXT_PUBLIC_API_URL` als Basis — Muster `custom-modules-api.ts`.
|
||||
*/
|
||||
|
||||
export const ACCOUNTING_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Dateiantwort der API (Base64) — wie beim Zertifikat-Manager. */
|
||||
export interface FileResponse {
|
||||
filename: string;
|
||||
content: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler mit HTTP-Status, maschinenlesbarer Kennung (`code`, falls die API
|
||||
* eine liefert) und deutscher Servermeldung.
|
||||
*/
|
||||
export class AccountingRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string | null,
|
||||
message: string,
|
||||
readonly details: unknown = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<AccountingRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | null = null;
|
||||
let details: unknown = null;
|
||||
try {
|
||||
const body = await res.json();
|
||||
const raw = body?.message;
|
||||
if (Array.isArray(raw)) message = raw.join(' ');
|
||||
else if (typeof raw === 'string') message = raw;
|
||||
if (typeof body?.code === 'string') code = body.code;
|
||||
details = body?.errors ?? null;
|
||||
} catch {
|
||||
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
|
||||
}
|
||||
return new AccountingRequestError(res.status, code, message, details);
|
||||
}
|
||||
|
||||
export async function accountingRequest<T>(
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown; form?: FormData } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
let body: BodyInit | undefined;
|
||||
if (init.form) {
|
||||
body = init.form;
|
||||
} else if (init.json !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(init.json);
|
||||
}
|
||||
const res = await fetch(`${ACCOUNTING_API_URL}${path}`, {
|
||||
method: init.method ?? 'GET',
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Startet den Download einer Base64-kodierten Datei im Browser (quick-261002-fm5).
|
||||
*
|
||||
* Clientseitiger Blob-Download: Object-URL plus Anker mit `download`-Attribut.
|
||||
* Genau dieser Weg wird vom Desktop-Client seit 1.9.2 als Datei gespeichert —
|
||||
* deshalb keine Tauri-spezifische Sonderbehandlung. Gleicher Koerper wie
|
||||
* `downloadBase64` im Zertifikat-Manager.
|
||||
*/
|
||||
export function downloadBase64(filename: string, content: string, mimeType: string): void {
|
||||
const bytes = atob(content);
|
||||
const byteArray = new Uint8Array(bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
byteArray[i] = bytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Kantinenabrechnung — API-Client (quick-261002-fm5). Konsumiert
|
||||
* `/modules/kantine-datev`. Hochgeladene Dateien gehen als Multipart-Feld
|
||||
* `file`; die API speichert sie nicht.
|
||||
*/
|
||||
|
||||
import { accountingRequest, type FileResponse } from '@/lib/accounting-request';
|
||||
|
||||
export type { FileResponse } from '@/lib/accounting-request';
|
||||
export { AccountingRequestError as KantineRequestError } from '@/lib/accounting-request';
|
||||
|
||||
const BASE = '/modules/kantine-datev';
|
||||
|
||||
export interface KantineSettings {
|
||||
beraterNr: string | null;
|
||||
mandantNr: string | null;
|
||||
lohnart: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface KantineSettingsInput {
|
||||
beraterNr: string;
|
||||
mandantNr: string;
|
||||
lohnart: string;
|
||||
}
|
||||
|
||||
export interface KantineIssue {
|
||||
row: number;
|
||||
field: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface KantineWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
params?: { months?: string[] };
|
||||
}
|
||||
|
||||
export interface KantinePreview {
|
||||
rowCount: number;
|
||||
abrechnungsMonat: string | null;
|
||||
totalCents: number;
|
||||
errors: KantineIssue[];
|
||||
warnings: KantineWarning[];
|
||||
canExport: boolean;
|
||||
blockedReason: 'settingsMissing' | 'errors' | null;
|
||||
}
|
||||
|
||||
function fileForm(file: File): FormData {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return form;
|
||||
}
|
||||
|
||||
export function getKantineSettings(): Promise<KantineSettings> {
|
||||
return accountingRequest(`${BASE}/settings`);
|
||||
}
|
||||
|
||||
export function saveKantineSettings(input: KantineSettingsInput): Promise<KantineSettings> {
|
||||
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
|
||||
}
|
||||
|
||||
export function previewKantineCsv(file: File): Promise<KantinePreview> {
|
||||
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
|
||||
}
|
||||
|
||||
export function exportKantineCsv(file: File): Promise<FileResponse> {
|
||||
return accountingRequest(`${BASE}/export`, { method: 'POST', form: fileForm(file) });
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
|
||||
*/
|
||||
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'tile';
|
||||
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'tile';
|
||||
|
||||
const ICONS: Record<string, ModuleIconId> = {
|
||||
'tender-radar': 'radar',
|
||||
@@ -15,6 +15,7 @@ const ICONS: Record<string, ModuleIconId> = {
|
||||
'cert-manager': 'certificate',
|
||||
domaincheck: 'globe',
|
||||
proxmox: 'server',
|
||||
'kantine-datev': 'utensils',
|
||||
};
|
||||
|
||||
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
|
||||
|
||||
@@ -59,6 +59,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
'kantine-datev': {
|
||||
component: dynamic(
|
||||
() => import('@/app/(portal)/modules/kantine-datev/page'),
|
||||
{ ssr: false },
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,6 +29,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
|
||||
'cert-manager': 'certManager.title',
|
||||
'dkv-fleet': 'dkvFleet.pageTitle',
|
||||
'tender-radar': 'tenderRadar.page.title',
|
||||
'kantine-datev': 'kantineDatev.title',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1618,12 +1618,93 @@
|
||||
"loadError": "Die eigenen Module konnten nicht geladen werden."
|
||||
}
|
||||
},
|
||||
"kantineDatev": {
|
||||
"title": "Kantinenabrechnung",
|
||||
"description": "Kantinen-CSV prüfen und als DATEV-Lohndatei für die Gehaltsabrechnung herunterladen.",
|
||||
"tabs": {
|
||||
"billing": "Abrechnung",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"loading": "Wird geladen …",
|
||||
"dropZone": {
|
||||
"placeholder": "CSV-Datei hierher ziehen oder klicken",
|
||||
"formats": ".csv, Semikolon-getrennt, UTF-8 oder Windows-1252",
|
||||
"clear": "Datei entfernen"
|
||||
},
|
||||
"noStorage": "Die hochgeladenen Daten werden nicht gespeichert.",
|
||||
"checking": "Datei wird geprüft …",
|
||||
"notConfigured": {
|
||||
"admin": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt. Ohne diese Angaben kann keine DATEV-Datei erstellt werden.",
|
||||
"adminAction": "Zu den Einstellungen",
|
||||
"user": "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen."
|
||||
},
|
||||
"summary": {
|
||||
"title": "Ergebnis der Prüfung",
|
||||
"rows": "Zeilen",
|
||||
"month": "Abrechnungsmonat",
|
||||
"total": "Gesamtbetrag",
|
||||
"noMonth": "nicht erkannt"
|
||||
},
|
||||
"warnings": {
|
||||
"title": "Hinweise",
|
||||
"multipleMonths": "Verschiedene Abrechnungsmonate erkannt: {months}. Alle Zeilen sollten im selben Abrechnungsmonat liegen."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Fehler in der Datei",
|
||||
"line": "Zeile",
|
||||
"field": "Feld",
|
||||
"message": "Meldung",
|
||||
"headerMissing": "Die Datei enthält keine Kopfzeile.",
|
||||
"headerColumns": "Die Kopfzeile hat zu wenige Spalten. Ist das Trennzeichen korrekt (Semikolon)?",
|
||||
"columnCount": "Die Zeile hat zu wenige Spalten.",
|
||||
"personalNrMissing": "Personalnummer fehlt",
|
||||
"personalNrNotNumeric": "Personalnummer muss numerisch sein",
|
||||
"betragMissing": "Betrag fehlt",
|
||||
"betragFormat": "Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)",
|
||||
"vonMissing": "Abrechnung von fehlt",
|
||||
"vonFormat": "Abrechnung von muss im Format TT.MM.JJJJ vorliegen",
|
||||
"bisMissing": "Abrechnung bis fehlt",
|
||||
"bisFormat": "Abrechnung bis muss im Format TT.MM.JJJJ vorliegen",
|
||||
"multiMonthRange": "Abrechnungszeitraum erstreckt sich über mehrere Monate",
|
||||
"noRows": "Die Datei enthält keine Datenzeilen.",
|
||||
"settingsMissing": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.",
|
||||
"hasErrors": "Die Datei enthält Fehler und kann nicht exportiert werden.",
|
||||
"qualityCheckFailed": "Die erzeugte Datei hat die Qualitätsprüfung nicht bestanden.",
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"fields": {
|
||||
"header": "Kopfzeile",
|
||||
"zeile": "Zeile",
|
||||
"datei": "Datei",
|
||||
"personalNr": "Personalnummer",
|
||||
"betrag": "Betrag",
|
||||
"abrechnungVon": "Abrechnung von",
|
||||
"abrechnungBis": "Abrechnung bis",
|
||||
"abrechnungVon/abrechnungBis": "Abrechnungszeitraum"
|
||||
},
|
||||
"download": {
|
||||
"button": "DATEV-Datei herunterladen",
|
||||
"busy": "Datei wird erstellt …",
|
||||
"done": "Die Datei {filename} wurde heruntergeladen."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Diese Angaben stehen im Kopf und in den Zeilen der DATEV-Datei. Sie gelten für alle Benutzer Ihres Mandanten und werden nur einmal hinterlegt.",
|
||||
"beraterNr": "Beraternummer",
|
||||
"mandantNr": "Mandantennummer",
|
||||
"lohnart": "Lohnart",
|
||||
"digitsOnly": "Bitte nur Ziffern eingeben (1 bis 10 Stellen).",
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert …",
|
||||
"saved": "Die Einstellungen wurden gespeichert."
|
||||
}
|
||||
},
|
||||
"moduleCategories": {
|
||||
"domain-tools": "Domains",
|
||||
"security-tools": "Sicherheit",
|
||||
"fleet": "Fuhrpark",
|
||||
"infrastructure": "Infrastruktur",
|
||||
"procurement": "Beschaffung",
|
||||
"accounting": "Finanzbuchhaltung",
|
||||
"custom-modules": "Eigene Module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1618,12 +1618,93 @@
|
||||
"loadError": "The custom modules could not be loaded."
|
||||
}
|
||||
},
|
||||
"kantineDatev": {
|
||||
"title": "Canteen billing",
|
||||
"description": "Check canteen CSV files and download them as a DATEV payroll file for the salary run.",
|
||||
"tabs": {
|
||||
"billing": "Billing",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"loading": "Loading …",
|
||||
"dropZone": {
|
||||
"placeholder": "Drag a CSV file here or click",
|
||||
"formats": ".csv, semicolon-separated, UTF-8 or Windows-1252",
|
||||
"clear": "Remove file"
|
||||
},
|
||||
"noStorage": "The uploaded data is not stored.",
|
||||
"checking": "Checking file …",
|
||||
"notConfigured": {
|
||||
"admin": "Consultant number, client number and wage type have not been set yet. Without them no DATEV file can be created.",
|
||||
"adminAction": "Go to settings",
|
||||
"user": "An administrator has to set the consultant number, client number and wage type first."
|
||||
},
|
||||
"summary": {
|
||||
"title": "Check result",
|
||||
"rows": "Rows",
|
||||
"month": "Billing month",
|
||||
"total": "Total amount",
|
||||
"noMonth": "not detected"
|
||||
},
|
||||
"warnings": {
|
||||
"title": "Notes",
|
||||
"multipleMonths": "Different billing months detected: {months}. All rows should be in the same billing month."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Errors in the file",
|
||||
"line": "Line",
|
||||
"field": "Field",
|
||||
"message": "Message",
|
||||
"headerMissing": "The file has no header line.",
|
||||
"headerColumns": "The header has too few columns. Is the separator correct (semicolon)?",
|
||||
"columnCount": "The line has too few columns.",
|
||||
"personalNrMissing": "Personnel number is missing",
|
||||
"personalNrNotNumeric": "Personnel number must be numeric",
|
||||
"betragMissing": "Amount is missing",
|
||||
"betragFormat": "Amount must be in German number format (comma as decimal separator)",
|
||||
"vonMissing": "Billing from is missing",
|
||||
"vonFormat": "Billing from must be in the format DD.MM.YYYY",
|
||||
"bisMissing": "Billing to is missing",
|
||||
"bisFormat": "Billing to must be in the format DD.MM.YYYY",
|
||||
"multiMonthRange": "The billing period spans several months",
|
||||
"noRows": "The file contains no data rows.",
|
||||
"settingsMissing": "Consultant number, client number and wage type have not been set yet.",
|
||||
"hasErrors": "The file contains errors and cannot be exported.",
|
||||
"qualityCheckFailed": "The generated file failed the quality check.",
|
||||
"request": "The request failed. Please try again."
|
||||
},
|
||||
"fields": {
|
||||
"header": "Header",
|
||||
"zeile": "Line",
|
||||
"datei": "File",
|
||||
"personalNr": "Personnel number",
|
||||
"betrag": "Amount",
|
||||
"abrechnungVon": "Billing from",
|
||||
"abrechnungBis": "Billing to",
|
||||
"abrechnungVon/abrechnungBis": "Billing period"
|
||||
},
|
||||
"download": {
|
||||
"button": "Download DATEV file",
|
||||
"busy": "Creating file …",
|
||||
"done": "The file {filename} has been downloaded."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "These values appear in the header and the lines of the DATEV file. They apply to all users of your tenant and only have to be set once.",
|
||||
"beraterNr": "Consultant number",
|
||||
"mandantNr": "Client number",
|
||||
"lohnart": "Wage type",
|
||||
"digitsOnly": "Please enter digits only (1 to 10 digits).",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"saved": "The settings have been saved."
|
||||
}
|
||||
},
|
||||
"moduleCategories": {
|
||||
"domain-tools": "Domains",
|
||||
"security-tools": "Security",
|
||||
"fleet": "Fleet",
|
||||
"infrastructure": "Infrastructure",
|
||||
"procurement": "Procurement",
|
||||
"accounting": "Financial accounting",
|
||||
"custom-modules": "Custom modules"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user