/**
* 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 { 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;
};
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('adminLanguages.code'), type: 'text', sortable: true, filterable: true, width: 90 },
{ key: 'label', label: t('adminLanguages.bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
{ key: 'status', label: t('adminLanguages.status'), type: 'text', sortable: true, filterable: true, width: 120 },
{ key: 'uiCount', label: t('adminLanguages.ui'), type: 'number', sortable: true, width: 80 },
{ key: 'gatewayCount', label: t('adminLanguages.api'), type: 'number', sortable: true, width: 80 },
{ key: 'entriesCount', label: t('adminLanguages.total'), type: 'number', sortable: true, width: 80 },
];
}
const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
const _isoChoices: { value: string; label: string }[] = [
{ value: 'de', label: 'de — Deutsch' }, { value: 'en', label: 'en — English' },
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
{ value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' },
{ value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' },
{ value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' },
{ value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' },
{ value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' },
{ value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' },
{ value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' },
{ value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' },
{ value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' },
{ value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' },
{ value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' },
{ value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' },
{ value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' },
{ value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' },
{ value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' },
{ value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' },
{ value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' },
{ value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' },
{ value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' },
{ value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' },
{ value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' },
{ value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' },
{ value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' },
{ value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' },
{ value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' },
{ value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' },
{ value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' },
{ value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' },
{ value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' },
{ value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' },
{ value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' },
{ value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' },
{ value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' },
{ value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' },
{ value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' },
{ value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' },
{ value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' },
];
// ---------------------------------------------------------------------------
// Progress overlay component
// ---------------------------------------------------------------------------
const _ProgressOverlay: React.FC<{ progress: ProgressInfo }> = ({ progress }) => {
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}
)}
);
};
// ---------------------------------------------------------------------------
// 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 busyRef = useRef(false);
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,
})),
);
} catch (e: any) {
setError(e.response?.data?.detail || e.message || 'Laden fehlgeschlagen');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
_load();
}, [_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 = _isoChoices.filter((c) => !existingCodes.has(c.value));
available.sort((a, b) => {
const aPrio = _PRIORITY_CODES.indexOf(a.value);
const bPrio = _PRIORITY_CODES.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]);
useEffect(() => {
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
setAddCode(addChoices[0].value);
}
}, [addChoices, addCode]);
const _fetchI18nEntriesFromBundle = useCallback(async (): Promise => {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const res = await fetch(`${normalizedBase}i18n-keys.json`);
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;
setError(null);
setProgress({ message: t('Basisset wird eingelesen…'), current: 0, total: 1 });
try {
const entries = await _fetchI18nEntriesFromBundle();
const res = await api.put('/api/i18n/sets/sync-xx', { entries });
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();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler beim Einlesen'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
}
};
const _updateOne = async (code: string) => {
if (busyRef.current) return;
busyRef.current = true;
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`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* 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)}`);
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();
} catch (e: any) {
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);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 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;
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();
await api.put('/api/i18n/sets/sync-xx', { entries });
step++;
setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps });
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler beim Basisset'), current: step, total: totalSteps, error: msg, done: true });
setError(msg);
setTimeout(() => { setProgress(null); busyRef.current = false; }, 3000);
return;
}
const errors: string[] = [];
for (const code of langCodes) {
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`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* 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)}`);
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) {
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();
setTimeout(() => { setProgress(null); busyRef.current = false; }, 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;
setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 });
try {
await api.post('/api/i18n/sets', { code });
setProgress({ message: t('Sprache erstellt. KI-Übersetzung läuft im Hintergrund.'), current: 1, total: 1, done: true });
await _load();
await refreshAvailableLanguages();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Fehler'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 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;
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' },
});
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();
} catch (e: any) {
const msg = e.response?.data?.detail || e.message;
setProgress({ message: t('Import fehlgeschlagen'), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
}
};
input.click();
};
const isBusy = progress !== null;
return (
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} />}
);
};
export default AdminLanguagesPage;