917 lines
31 KiB
TypeScript
917 lines
31 KiB
TypeScript
/**
|
|
* 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 (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
|
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
|
{t('wird aktualisiert…')}
|
|
</span>
|
|
);
|
|
}
|
|
if (r.status === 'generating') {
|
|
return (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-warning, #e6a700)' }}>
|
|
<FaSync style={{ animation: 'spin 1s linear infinite', fontSize: '0.85em' }} />
|
|
{t('wird erzeugt…')}
|
|
</span>
|
|
);
|
|
}
|
|
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 (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
background: 'rgba(var(--bg-rgb, 255,255,255), 0.85)',
|
|
backdropFilter: 'blur(2px)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 50,
|
|
borderRadius: 'var(--border-radius, 8px)',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: 'var(--card-bg, #fff)',
|
|
border: '1px solid var(--border-color, #ddd)',
|
|
borderRadius: 'var(--border-radius, 8px)',
|
|
padding: '2rem 2.5rem',
|
|
minWidth: 320,
|
|
maxWidth: 420,
|
|
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<p style={{ fontWeight: 600, fontSize: '1.05rem', margin: '0 0 1rem', lineHeight: 1.3 }}>
|
|
{title}
|
|
</p>
|
|
{showLangMeter && (
|
|
<>
|
|
<p
|
|
style={{
|
|
margin: '0 0 0.35rem',
|
|
fontSize: '1rem',
|
|
fontWeight: 600,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
color: 'var(--text-primary, #1e293b)',
|
|
}}
|
|
>
|
|
{progress.current} / {progress.total}
|
|
</p>
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: 8,
|
|
background: 'var(--border-color, #e2e8f0)',
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
marginBottom: showEntryMeter ? '0.85rem' : 0,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: `${pctLang}%`,
|
|
height: '100%',
|
|
background: barBg,
|
|
borderRadius: 4,
|
|
transition: 'width 0.3s ease',
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{showEntryMeter && (
|
|
<>
|
|
<p
|
|
style={{
|
|
margin: '0 0 0.35rem',
|
|
fontSize: '1rem',
|
|
fontWeight: 600,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
color: 'var(--text-primary, #1e293b)',
|
|
}}
|
|
>
|
|
{entryJShown} / {entryTotal}
|
|
</p>
|
|
<div className={styles.i18nKeysProgressTrack}>
|
|
<div
|
|
className={styles.i18nKeysProgressFill}
|
|
style={{
|
|
width: `${entryBarPct}%`,
|
|
background: barBg,
|
|
transition: awaitingAi ? 'width 0.25s ease-out' : 'width 0.35s ease',
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{progress.error && (
|
|
<p style={{ color: 'var(--error-color, #c53030)', fontSize: '0.85rem', marginTop: '0.75rem' }}>
|
|
{progress.error}
|
|
</p>
|
|
)}
|
|
{canAbort && (
|
|
<button
|
|
type="button"
|
|
onClick={onAbort}
|
|
style={{
|
|
marginTop: '1.25rem',
|
|
padding: '0.5rem 1.25rem',
|
|
borderRadius: 6,
|
|
border: '1px solid var(--border-color, #cbd5e1)',
|
|
background: 'var(--surface-color, #f8fafc)',
|
|
color: 'var(--text-primary, #1e293b)',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t('Abbrechen')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const AdminLanguagesPage: React.FC = () => {
|
|
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
|
const { confirm, ConfirmDialog } = useConfirm();
|
|
const [rows, setRows] = useState<LangRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [addCode, setAddCode] = useState('');
|
|
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [isoCatalog, setIsoCatalog] = useState<IsoCatalogResponse>({ priorityCodes: [], choices: [] });
|
|
const busyRef = useRef(false);
|
|
const abortRef = useRef<AbortController | null>(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<ProgressInfo, 'current' | 'total'> & Partial<Pick<ProgressInfo, 'progressHeading'>>,
|
|
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<any[]> => {
|
|
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 (
|
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
|
|
<header>
|
|
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
|
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
|
{error && !progress && (
|
|
<p style={{ color: 'var(--error-color, #c53030)' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</header>
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
|
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
|
|
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
|
|
</button>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('Suche…')}
|
|
style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
|
|
/>
|
|
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
|
|
{t('Alle aktualisieren')}
|
|
</button>
|
|
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
|
|
<FaFileExport /> {t('Export')}
|
|
</button>
|
|
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
|
|
<FaFileImport /> {t('Import')}
|
|
</button>
|
|
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
|
|
<span style={{ opacity: 0.7 }}>{t('Neue Sprache')}</span>
|
|
<select
|
|
value={addCode}
|
|
onChange={(e) => setAddCode(e.target.value)}
|
|
style={{ padding: '0.35rem 0.5rem' }}
|
|
disabled={isBusy}
|
|
>
|
|
{addChoices.map((c) => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
|
|
{t('Hinzufügen')}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
|
<FormGeneratorTable
|
|
data={displayRows}
|
|
columns={_getColumns(t)}
|
|
loading={loading}
|
|
pagination={false}
|
|
selectable={false}
|
|
searchable={false}
|
|
customActions={[
|
|
{
|
|
id: 'sync-xx',
|
|
title: t('UI-Keys einlesen'),
|
|
icon: <FaSync />,
|
|
onClick: () => _syncXx(),
|
|
visible: (row: LangRow) => row.id === 'xx',
|
|
},
|
|
{
|
|
id: 'upd',
|
|
title: t('Aktualisieren'),
|
|
icon: <FaRedo />,
|
|
onClick: (row: LangRow) => _updateOne(row.id),
|
|
visible: (row: LangRow) => row.id !== 'xx',
|
|
},
|
|
{
|
|
id: 'dl',
|
|
title: t('Herunterladen'),
|
|
icon: <FaDownload />,
|
|
onClick: (row: LangRow) => _download(row.id),
|
|
},
|
|
{
|
|
id: 'del',
|
|
title: t('Löschen'),
|
|
icon: <FaTrash />,
|
|
onClick: (row: LangRow) => _delete(row.id),
|
|
visible: (row: LangRow) => row.id !== 'xx',
|
|
},
|
|
]}
|
|
emptyMessage={t('Keine Einträge')}
|
|
/>
|
|
|
|
{progress && <_ProgressOverlay progress={progress} onAbort={_abortRunning} />}
|
|
</div>
|
|
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminLanguagesPage;
|