a222711ad9
- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE - ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt - @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage - Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
'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 { useCanManageModule } from '@/lib/use-module-capability';
|
|
|
|
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');
|
|
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
|
|
const canManage = useCanManageModule('kantine-datev') === true;
|
|
|
|
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 (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
|
const activeTab = tab === 'settings' && !canManage ? '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}
|
|
canManage={canManage}
|
|
onOpenSettings={() => setTab('settings')}
|
|
/>
|
|
) : (
|
|
<SettingsTab settings={settings} onSaved={setSettings} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BillingTab({
|
|
settings,
|
|
settingsError,
|
|
canManage,
|
|
onOpenSettings,
|
|
}: {
|
|
settings: KantineSettings | null;
|
|
settingsError: boolean;
|
|
canManage: 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>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
|
|
{canManage && (
|
|
<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>
|
|
);
|
|
}
|