/** * SysAdmin: UI language sets (DB-backed i18n). */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaSync, FaTrash } from 'react-icons/fa'; import api from '../../api'; import axios from 'axios'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable'; import { useConfirm } from '../../hooks/useConfirm'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './Admin.module.css'; type LangRow = { id: string; label: string; status: string; entriesCount: number; uiCount: number; gatewayCount: number; updating: boolean; }; type ProgressInfo = { message: string; current: number; total: number; error?: string; done?: boolean; /** Short title (e.g. language label) to avoid long sentences in the overlay. */ progressHeading?: string; /** Keys in language set before sync (from sync-diff). */ keysCurrent?: number; /** New keys vs xx before PUT (from sync-diff). */ keysPending?: number; /** Master (xx) key count (from sync-diff). */ keysMasterTotal?: number; /** Keys AI-translated in last PUT (optional). */ keysTranslated?: number; }; function _getColumns(t: (key: string) => string): ColumnConfig[] { return [ { key: 'id', label: t('Code'), type: 'text', sortable: true, filterable: true, width: 90 }, { key: 'label', label: t('Bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 }, { key: 'status', label: t('Status'), type: 'text', sortable: true, filterable: true, width: 160, formatter: (_val: any, row: any) => { const r = row as LangRow; if (r.updating) { return ( {t('wird aktualisiert…')} ); } if (r.status === 'generating') { return ( {t('wird erzeugt…')} ); } return r.status; }, }, { key: 'uiCount', label: t('UI'), type: 'number', sortable: true, width: 80 }, { key: 'gatewayCount', label: t('API'), type: 'number', sortable: true, width: 80 }, { key: 'entriesCount', label: t('Gesamt'), type: 'number', sortable: true, width: 80 }, ]; } // ISO 639 catalog (codes + native labels + priority order) is provided by the // gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here -- // any divergence between frontend and backend caused subtle bugs (e.g. user // could create a language code that the AI translation prompt did not know how // to label). The catalog is fetched once on mount and held in component state. type IsoChoice = { value: string; label: string }; type IsoCatalogResponse = { priorityCodes: string[]; choices: IsoChoice[] }; function _isAbortError(e: unknown): boolean { if (axios.isCancel(e)) return true; if (e && typeof e === 'object') { const err = e as { code?: string; name?: string }; if (err.code === 'ERR_CANCELED' || err.name === 'AbortError' || err.name === 'CanceledError') return true; } return false; } // --------------------------------------------------------------------------- // Progress overlay component // --------------------------------------------------------------------------- const _ProgressOverlay: React.FC<{ progress: ProgressInfo; onAbort?: () => void; }> = ({ progress, onAbort }) => { const { t } = useLanguage(); const canAbort = Boolean(onAbort && !progress.done && !progress.error); const master = progress.keysMasterTotal; const pending = progress.keysPending ?? 0; const cur = progress.keysCurrent ?? 0; const translated = progress.keysTranslated; const hasMaster = master != null && master > 0; const entryJ = pending > 0 ? (translated ?? 0) : cur; const entryTotal = pending > 0 ? pending : (master ?? 0); const showEntryMeter = hasMaster && entryTotal > 0; const awaitingAi = pending > 0 && translated === undefined && !progress.done && !progress.error; const [softEntryPct, setSoftEntryPct] = useState(0); useEffect(() => { if (!awaitingAi) { setSoftEntryPct(0); return; } const started = Date.now(); const id = window.setInterval(() => { const elapsed = Date.now() - started; const cap = 94; setSoftEntryPct(Math.min(cap, cap * (1 - Math.exp(-elapsed / 14000)))); }, 220); return () => clearInterval(id); }, [awaitingAi]); let entryPct = 0; if (pending > 0) { if (translated !== undefined && pending > 0) { entryPct = Math.min(100, Math.round((translated / pending) * 100)); } } else if (master && master > 0) { entryPct = Math.min(100, Math.round((cur / master) * 100)); } const entryBarPct = awaitingAi ? softEntryPct : entryPct; const entryJShown = awaitingAi ? Math.min(pending, Math.max(0, Math.floor((softEntryPct / 100) * pending))) : entryJ; const showLangMeter = progress.total > 1 || !showEntryMeter; const pctLang = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; const title = progress.progressHeading ?? progress.message; const barBg = progress.error ? 'var(--error-color, #c53030)' : 'var(--primary-color, #3182ce)'; return (

{title}

{showLangMeter && ( <>

{progress.current} / {progress.total}

)} {showEntryMeter && ( <>

{entryJShown} / {entryTotal}

)} {progress.error && (

{progress.error}

)} {canAbort && ( )}
); }; // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export const AdminLanguagesPage: React.FC = () => { const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage(); const { confirm, ConfirmDialog } = useConfirm(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [addCode, setAddCode] = useState(''); const [progress, setProgress] = useState(null); const [search, setSearch] = useState(''); const [isoCatalog, setIsoCatalog] = useState({ priorityCodes: [], choices: [] }); const busyRef = useRef(false); const abortRef = useRef(null); useEffect(() => { let cancelled = false; (async () => { try { const res = await api.get('/api/i18n/iso-choices'); if (cancelled) return; const data = res.data as IsoCatalogResponse; setIsoCatalog({ priorityCodes: Array.isArray(data?.priorityCodes) ? data.priorityCodes : [], choices: Array.isArray(data?.choices) ? data.choices : [], }); } catch (e) { console.error('Failed to load ISO language catalog from /api/i18n/iso-choices:', e); } })(); return () => { cancelled = true; }; }, []); const _endProgressSoon = useCallback((ms: number) => { window.setTimeout(() => { setProgress(null); busyRef.current = false; abortRef.current = null; }, ms); }, []); const _abortRunning = useCallback(() => { abortRef.current?.abort(); }, []); const _load = useCallback(async () => { try { setLoading(true); setError(null); const res = await api.get('/api/i18n/codes'); const list = (res.data || []) as any[]; setRows( list.map((r) => ({ id: r.code, label: r.label || r.code, status: r.status || '', entriesCount: r.entriesCount ?? 0, uiCount: r.uiCount ?? 0, gatewayCount: r.gatewayCount ?? 0, updating: !!r.updating, })), ); } catch (e: any) { setError(e.response?.data?.detail || e.message || 'Laden fehlgeschlagen'); } finally { setLoading(false); } }, []); /** Einheitliche Abbruch-Meldung + Overlay-Ausblendung; optional Listen neu laden (teilweise fertige „Alle aktualisieren“). */ const _finishProgressAborted = useCallback( async ( partial: Pick & Partial>, ms: number, refreshLists: boolean, ) => { setProgress({ message: t('Vorgang abgebrochen.'), error: t('Die Operation wurde abgebrochen.'), done: true, ...partial, }); if (refreshLists) { await _load(); await refreshAvailableLanguages(); await reloadLanguage(); } _endProgressSoon(ms); }, [t, _endProgressSoon, _load, refreshAvailableLanguages, reloadLanguage], ); useEffect(() => { _load(); }, [_load]); const _hasUpdating = rows.some((r) => r.updating || r.status === 'generating'); useEffect(() => { if (!_hasUpdating) return; const id = window.setInterval(() => { _load(); }, 5000); return () => window.clearInterval(id); }, [_hasUpdating, _load]); const displayRows = useMemo(() => { const term = search.trim().toLowerCase(); const filtered = term ? rows.filter((r) => r.id.toLowerCase().includes(term) || r.label.toLowerCase().includes(term) || r.status.toLowerCase().includes(term)) : rows; return [...filtered].sort((a, b) => { if (a.id === 'xx') return -1; if (b.id === 'xx') return 1; return a.id.localeCompare(b.id); }); }, [rows, search]); const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]); const addChoices = useMemo(() => { const available = isoCatalog.choices.filter((c) => !existingCodes.has(c.value)); const priority = isoCatalog.priorityCodes; available.sort((a, b) => { const aPrio = priority.indexOf(a.value); const bPrio = priority.indexOf(b.value); if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio; if (aPrio !== -1) return -1; if (bPrio !== -1) return 1; return a.label.localeCompare(b.label); }); return available; }, [existingCodes, isoCatalog]); useEffect(() => { if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) { setAddCode(addChoices[0].value); } }, [addChoices, addCode]); const _fetchI18nEntriesFromBundle = useCallback(async (signal?: AbortSignal): Promise => { const base = import.meta.env.BASE_URL || '/'; const normalizedBase = base.endsWith('/') ? base : `${base}/`; const res = await fetch(`${normalizedBase}i18n-keys.json`, { signal }); if (!res.ok) { throw new Error( t('i18n-keys.json nicht gefunden. Bitte Frontend neu bauen oder Dev-Server starten.'), ); } const data = await res.json(); if (!Array.isArray(data)) { throw new Error(t('Ungültiges i18n-keys.json')); } return data; }, [t]); // --- Actions with progress ------------------------------------------------ const _syncXx = async () => { if (busyRef.current) return; busyRef.current = true; const ac = new AbortController(); abortRef.current = ac; const { signal } = ac; setError(null); setProgress({ message: t('Basisset wird eingelesen…'), current: 0, total: 1 }); try { const entries = await _fetchI18nEntriesFromBundle(signal); const res = await api.put('/api/i18n/sets/sync-xx', { entries }, { signal }); const d = res.data || {}; const addedCount = d.added?.length ?? 0; const removedCount = d.removed?.length ?? 0; const entriesCount = d.entriesCount ?? 0; setProgress({ message: t('Basisset synchronisiert: {added} neu, {removed} entfernt, {total} Einträge.', { added: String(addedCount), removed: String(removedCount), total: String(entriesCount), }), current: 1, total: 1, done: true, }); await _load(); await refreshAvailableLanguages(); await reloadLanguage(); _endProgressSoon(2500); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ current: 0, total: 1 }, 2200, false); return; } const msg = e.response?.data?.detail || e.message; setProgress({ message: t('Fehler beim Einlesen'), current: 0, total: 1, error: msg, done: true }); setError(msg); _endProgressSoon(2500); } }; const _updateOne = async (code: string) => { if (busyRef.current) return; busyRef.current = true; const ac = new AbortController(); abortRef.current = ac; const { signal } = ac; setError(null); const label = rows.find((r) => r.id === code)?.label || code; let keysCurrent: number | undefined; let keysPending: number | undefined; let keysMasterTotal: number | undefined; try { const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`, { signal }); keysCurrent = dr.data?.currentEntryCount; keysPending = dr.data?.addedCount; keysMasterTotal = dr.data?.masterEntryCount; } catch (e) { if (_isAbortError(e)) { await _finishProgressAborted({ progressHeading: label, current: 0, total: 1 }, 2200, false); return; } /* sync-diff optional */ } setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), progressHeading: label, current: 0, total: 1, keysCurrent, keysPending, keysMasterTotal, }); try { const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`, {}, { signal }); const d = putRes.data || {}; const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0); setProgress({ message: t('{lang} aktualisiert.', { lang: label }), progressHeading: label, current: 1, total: 1, done: true, keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent, keysPending: pendingAfterPut, keysMasterTotal, keysTranslated: typeof d.translated === 'number' ? d.translated : undefined, }); await _load(); await refreshAvailableLanguages(); await reloadLanguage(); _endProgressSoon(2000); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ progressHeading: label, current: 0, total: 1 }, 2200, false); return; } const msg = e.response?.data?.detail || e.message; setProgress({ message: t('Fehler bei {lang}', { lang: label }), progressHeading: label, current: 0, total: 1, error: msg, done: true, }); setError(msg); _endProgressSoon(2000); } }; const _updateAll = async () => { if (busyRef.current) return; const ok = await confirm(t('Alle Sprachsets jetzt mit dem Basisset synchronisieren und per KI aktualisieren?'), { confirmLabel: t('Alle aktualisieren'), cancelLabel: t('Abbrechen'), }); if (!ok) return; busyRef.current = true; const ac = new AbortController(); abortRef.current = ac; const { signal } = ac; setError(null); const langCodes = rows.filter((r) => r.id !== 'xx').map((r) => r.id); const totalSteps = 1 + langCodes.length; let step = 0; setProgress({ message: t('Basisset wird eingelesen…'), current: step, total: totalSteps }); try { const entries = await _fetchI18nEntriesFromBundle(signal); await api.put('/api/i18n/sets/sync-xx', { entries }, { signal }); step++; setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps }); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ current: step, total: totalSteps }, 2800, false); return; } const msg = e.response?.data?.detail || e.message; setProgress({ message: t('Fehler beim Basisset'), current: step, total: totalSteps, error: msg, done: true }); setError(msg); _endProgressSoon(3000); return; } const errors: string[] = []; for (const code of langCodes) { if (signal.aborted) { setProgress({ message: t('Vorgang abgebrochen.'), current: step, total: totalSteps, error: t('Die Operation wurde abgebrochen.'), done: true, }); await _load(); await refreshAvailableLanguages(); await reloadLanguage(); _endProgressSoon(3500); return; } const label = rows.find((r) => r.id === code)?.label || code; let keysCurrent: number | undefined; let keysPending: number | undefined; let keysMasterTotal: number | undefined; try { const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`, { signal }); keysCurrent = dr.data?.currentEntryCount; keysPending = dr.data?.addedCount; keysMasterTotal = dr.data?.masterEntryCount; } catch (e) { if (_isAbortError(e)) { await _finishProgressAborted({ progressHeading: label, current: step + 1, total: totalSteps }, 3500, true); return; } /* sync-diff optional */ } setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), current: step + 1, total: totalSteps, keysCurrent, keysPending, keysMasterTotal, }); try { const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`, {}, { signal }); const d = putRes.data || {}; const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0); setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), progressHeading: label, current: step + 1, total: totalSteps, keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent, keysPending: pendingAfterPut, keysMasterTotal, keysTranslated: typeof d.translated === 'number' ? d.translated : undefined, }); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ progressHeading: label, current: step + 1, total: totalSteps }, 3500, true); return; } errors.push(`${code}: ${e.response?.data?.detail || e.message}`); } step++; } if (errors.length > 0) { const errMsg = errors.join('; '); setProgress({ message: t('{ok} von {total} Sprachen aktualisiert.', { ok: String(langCodes.length - errors.length), total: String(langCodes.length) }), current: step, total: totalSteps, error: errMsg, done: true, }); setError(errMsg); } else { setProgress({ message: t('Alle {count} Sprachen erfolgreich aktualisiert.', { count: String(langCodes.length) }), current: totalSteps, total: totalSteps, done: true, }); } await _load(); await refreshAvailableLanguages(); await reloadLanguage(); _endProgressSoon(errors.length > 0 ? 5000 : 2500); }; // --- Other actions (unchanged logic, but with busy guard) ----------------- const _delete = async (code: string) => { if (busyRef.current) return; if (code === 'xx') return; const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), { confirmLabel: t('Löschen'), cancelLabel: t('Abbrechen'), variant: 'danger', }); if (!ok) return; try { await api.delete(`/api/i18n/sets/${encodeURIComponent(code)}`); await _load(); await refreshAvailableLanguages(); await reloadLanguage(); } catch (e: any) { setError(e.response?.data?.detail || e.message); } }; const _download = async (code: string) => { try { const response = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/download`, { responseType: 'blob', }); const blob = new Blob([response.data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ui-language-${code}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e: any) { setError(e.response?.data?.detail || e.message); } }; const _add = async () => { if (busyRef.current) return; const code = String(addCode).trim().toLowerCase(); if (!code) return; const go = await confirm( t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'), { confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') }, ); if (!go) return; busyRef.current = true; const ac = new AbortController(); abortRef.current = ac; const { signal } = ac; setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 }); try { await api.post('/api/i18n/sets', { code }, { signal }); setProgress({ message: t('Sprache erstellt. KI-Übersetzung läuft im Hintergrund.'), current: 1, total: 1, done: true }); await _load(); await refreshAvailableLanguages(); _endProgressSoon(2500); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ current: 0, total: 1 }, 2200, false); return; } const msg = e.response?.data?.detail || e.message; setProgress({ message: t('Fehler'), current: 0, total: 1, error: msg, done: true }); setError(msg); _endProgressSoon(2500); } }; const _exportAll = async () => { try { const response = await api.get('/api/i18n/export', { responseType: 'blob' }); const blob = new Blob([response.data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'ui-languages-export.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e: any) { setError(e.response?.data?.detail || e.message); } }; const _importFile = async () => { if (busyRef.current) return; const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async () => { const file = input.files?.[0]; if (!file) return; const ok = await confirm( t('Sprachdatenbank aus Datei importieren? Bestehende Sets werden überschrieben.'), { confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') }, ); if (!ok) return; busyRef.current = true; const ac = new AbortController(); abortRef.current = ac; const { signal } = ac; setProgress({ message: t('Importiere…'), current: 0, total: 1 }); try { const formData = new FormData(); formData.append('file', file); const res = await api.post('/api/i18n/import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, signal, }); const d = res.data || {}; setError(null); setProgress({ message: t('Import abgeschlossen: {created} erstellt, {updated} aktualisiert.', { created: String(d.created?.length ?? 0), updated: String(d.updated?.length ?? 0), }), current: 1, total: 1, done: true, }); await _load(); await refreshAvailableLanguages(); await reloadLanguage(); _endProgressSoon(2500); } catch (e: any) { if (_isAbortError(e)) { await _finishProgressAborted({ current: 0, total: 1 }, 2200, false); return; } const msg = e.response?.data?.detail || e.message; setProgress({ message: t('Import fehlgeschlagen'), current: 0, total: 1, error: msg, done: true }); setError(msg); _endProgressSoon(2500); } }; input.click(); }; const isBusy = progress !== null; return (

{t('UI-Sprachen')}

{t('Globale Sprachsets verwalten (SysAdmin).')}

{error && !progress && (

{error}

)}
setSearch(e.target.value)} placeholder={t('Suche…')} style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }} /> {t('Neue Sprache')}
, onClick: () => _syncXx(), visible: (row: LangRow) => row.id === 'xx', }, { id: 'upd', title: t('Aktualisieren'), icon: , onClick: (row: LangRow) => _updateOne(row.id), visible: (row: LangRow) => row.id !== 'xx', }, { id: 'dl', title: t('Herunterladen'), icon: , onClick: (row: LangRow) => _download(row.id), }, { id: 'del', title: t('Löschen'), icon: , onClick: (row: LangRow) => _delete(row.id), visible: (row: LangRow) => row.id !== 'xx', }, ]} emptyMessage={t('Keine Einträge')} /> {progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
); }; export default AdminLanguagesPage;