BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite
Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft gemaess konfigurierbarem Intervall und laedt current.json/next.json von npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume BANKDATA_DIR (./data/bankdata -> /app/bankdata). Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten (Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format) + atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell. Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin. Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update- Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter + Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import CompanyProfileSettings from './pages/settings/CompanyProfileSettings';
|
||||
import ViewSettings from './pages/settings/ViewSettings';
|
||||
import PortalSettings from './pages/settings/PortalSettings';
|
||||
import DeadlineSettings from './pages/settings/DeadlineSettings';
|
||||
import BankDataSettings from './pages/settings/BankDataSettings';
|
||||
import EmailProviders from './pages/settings/EmailProviders';
|
||||
import DatabaseBackup from './pages/settings/DatabaseBackup';
|
||||
import FactoryDefaults from './pages/settings/FactoryDefaults';
|
||||
@@ -231,6 +232,7 @@ function App() {
|
||||
<Route path="settings/view" element={<ViewSettings />} />
|
||||
<Route path="settings/portal" element={<PortalSettings />} />
|
||||
<Route path="settings/deadlines" element={<DeadlineSettings />} />
|
||||
<Route path="settings/bank-data" element={<BankDataSettings />} />
|
||||
<Route path="settings/email-providers" element={<EmailProviders />} />
|
||||
<Route path="settings/database-backup" element={<DatabaseBackup />} />
|
||||
<Route path="settings/factory-defaults" element={<FactoryDefaults />} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash } from 'lucide-react';
|
||||
import { Settings as SettingsIcon, Code, Store, Clock, Calendar, UserCog, ChevronRight, Building2, FileType, Eye, Globe, Mail, Database, Shield, ShieldAlert, ShieldOff, FileText, FileEdit, PackageCheck, Hash, Landmark } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const { hasPermission, developerMode, setDeveloperMode } = useAuth();
|
||||
@@ -146,6 +146,23 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings/bank-data"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-100 transition-colors">
|
||||
<Landmark className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors flex items-center gap-2">
|
||||
Bankdaten (BLZ)
|
||||
<ChevronRight className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Datenstand der Bankleitzahlen (BIC/Bank aus IBAN) + Auto-Update-Intervall.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings/pdf-templates"
|
||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { appSettingsApi, blzDataApi } from '../../services/api';
|
||||
import Card from '../../components/ui/Card';
|
||||
import Input from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { ArrowLeft, Landmark, RefreshCw, CheckCircle, AlertTriangle, Info, Download } from 'lucide-react';
|
||||
|
||||
function fmt(dt: string | null): string {
|
||||
if (!dt) return '–';
|
||||
const d = new Date(dt);
|
||||
return Number.isNaN(d.getTime()) ? '–' : d.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
export default function BankDataSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: statusData, isLoading, refetch } = useQuery({
|
||||
queryKey: ['blz-status'],
|
||||
queryFn: () => blzDataApi.getStatus(),
|
||||
});
|
||||
const status = statusData?.data;
|
||||
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => appSettingsApi.getAll(),
|
||||
});
|
||||
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [intervalDays, setIntervalDays] = useState('30');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsData?.data) {
|
||||
setAutoUpdate((settingsData.data.blzAutoUpdateEnabled ?? 'true') === 'true');
|
||||
setIntervalDays(settingsData.data.blzUpdateIntervalDays || '30');
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blz-status'] });
|
||||
setHasChanges(false);
|
||||
toast.success('Einstellungen gespeichert.');
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Speichern fehlgeschlagen.'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (force: boolean) => blzDataApi.updateNow(force),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blz-status'] });
|
||||
refetch();
|
||||
toast.success(res.data?.result?.message || 'Aktualisierung abgeschlossen.');
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Aktualisierung fehlgeschlagen.'),
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const n = parseInt(intervalDays, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 365) {
|
||||
toast.error('Das Intervall muss zwischen 1 und 365 Tagen liegen.');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate({
|
||||
blzAutoUpdateEnabled: autoUpdate ? 'true' : 'false',
|
||||
blzUpdateIntervalDays: String(n),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Laden...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Landmark className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Bankdaten (BLZ)</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mb-6 flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-900 space-y-1">
|
||||
<p>
|
||||
Diese Daten (Bundesbank-Bankleitzahlen) füllen im Bankkarten-Formular beim
|
||||
IBAN-Abruf <strong>BIC und Bankname</strong> aus. Der Abruf läuft rein lokal –
|
||||
<strong> keine IBAN verlässt den Server</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Das Auto-Update lädt lediglich eine <strong>öffentliche Datendatei</strong> (npm/jsDelivr) in
|
||||
ein Volume – ebenfalls ohne Kundendaten. Ist es aus oder scheitert der Abruf,
|
||||
wird automatisch der ins Image gebackene Datenstand verwendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<Card title="Aktueller Datenstand">
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3 text-sm">
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Aktive Quelle</dt>
|
||||
<dd className="font-medium">
|
||||
{status?.activeSource === 'volume' ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-700">
|
||||
<CheckCircle className="w-4 h-4" /> Volume (aktualisiert)
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-gray-700">
|
||||
Image (eingebaut)
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Aktive Version</dt>
|
||||
<dd className="font-medium">{status?.activeVersion || '–'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Einträge (Banken)</dt>
|
||||
<dd className="font-medium">{status?.entryCount?.toLocaleString('de-DE') ?? '–'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Eingebaute Version (Fallback)</dt>
|
||||
<dd className="font-medium">{status?.builtinVersion || '–'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Zuletzt aktualisiert</dt>
|
||||
<dd className="font-medium">{fmt(status?.lastUpdatedAt ?? null)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Zuletzt geprüft</dt>
|
||||
<dd className="font-medium">{fmt(status?.lastCheckedAt ?? null)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Neueste verfügbare Version</dt>
|
||||
<dd className="font-medium">
|
||||
{status?.latestVersion || '– (noch nicht geprüft)'}
|
||||
{status?.updateAvailable && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-semibold">
|
||||
<Download className="w-3 h-3" /> Update verfügbar
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-100 pb-2">
|
||||
<dt className="text-gray-500">Delta gültig ab</dt>
|
||||
<dd className="font-medium">{status?.nextValidFrom ? fmt(status.nextValidFrom) : '–'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{status?.lastError && (
|
||||
<div className="mt-4 flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>Letzter Fehler beim Abruf: {status.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Button onClick={() => updateMutation.mutate(false)} disabled={updateMutation.isPending}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${updateMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{updateMutation.isPending ? 'Prüfe…' : 'Jetzt prüfen & aktualisieren'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => updateMutation.mutate(true)} disabled={updateMutation.isPending}>
|
||||
Neu laden erzwingen
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Auto-Update-Einstellungen */}
|
||||
<Card title="Automatische Aktualisierung" className="mt-6">
|
||||
<label className="flex items-center gap-3 mb-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoUpdate}
|
||||
onChange={(e) => { setAutoUpdate(e.target.checked); setHasChanges(true); }}
|
||||
className="rounded w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">Auto-Update aktiviert</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm text-gray-700">Prüf-/Update-Intervall:</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={intervalDays}
|
||||
onChange={(e) => { setIntervalDays(e.target.value); setHasChanges(true); }}
|
||||
className="w-24"
|
||||
disabled={!autoUpdate}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Tage</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Die Bundesbank ändert die Bankleitzahlen bis zu 4×/Jahr. Ein Wert von 30 (monatlich)
|
||||
reicht komfortabel. Der Server prüft täglich, ob laut Intervall eine Aktualisierung fällig ist.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={handleSave} disabled={!hasChanges || saveMutation.isPending}>
|
||||
{saveMutation.isPending ? 'Speichere…' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1139,6 +1139,36 @@ export const appSettingsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// BLZ-/Bankdaten (Bundesbank-Bankleitzahlen) – Status + Auto-Update
|
||||
export interface BlzStatus {
|
||||
activeSource: 'volume' | 'builtin';
|
||||
activeVersion: string;
|
||||
builtinVersion: string;
|
||||
entryCount: number;
|
||||
nextValidFrom: string | null;
|
||||
fetchedAt: string | null;
|
||||
autoUpdateEnabled: boolean;
|
||||
intervalDays: number;
|
||||
lastCheckedAt: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export const blzDataApi = {
|
||||
getStatus: async () => {
|
||||
const res = await api.get<ApiResponse<BlzStatus>>('/settings/blz');
|
||||
return res.data;
|
||||
},
|
||||
updateNow: async (force = false) => {
|
||||
const res = await api.post<ApiResponse<{ result: { changed: boolean; version: string; message: string }; status: BlzStatus }>>(
|
||||
`/settings/blz/update-now${force ? '?force=1' : ''}`,
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Backup & Restore
|
||||
export interface BackupInfo {
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user