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:
2026-08-12 13:15:07 +02:00
co-authored by Claude Opus 4.8
parent 73bd72bd9f
commit 91fbe12299
16 changed files with 788 additions and 7 deletions
+2
View File
@@ -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 />} />
+18 -1
View File
@@ -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>
);
}
+30
View File
@@ -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;