issues fixed

This commit is contained in:
ValueOn AG 2026-04-08 22:29:35 +02:00
parent db64505915
commit 9661a0f7a5
11 changed files with 35071 additions and 579 deletions

View file

@ -1,112 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2025 Patrick Motsch
"""
i18n-Index für DB-gestützte UI-Sprachen (de = Key, siehe wiki).
Dieses Skript **regeneriert** `i18n_ui_strings_index.json` aus den im Projekt verwendeten
`t('')`- und `t("")`-Aufrufen in den angegebenen Quellfiles. Es wendet **keine**
Code-Änderungen an Migration neuer Texte erfolgt im TSX mit `t()`, anschließend Index
mit `--write` aktualisieren und fehlende Keys in der Admin-UI (UI-Sprachen) nachziehen.
Nutzung (im Ordner frontend_nyla):
python scripts/apply_i18n_from_index.py --write
python scripts/apply_i18n_from_index.py # nur prüfen: gleiche Keys wie in JSON?
Konvention: deutscher Klartext ist der Key (siehe `2026-04-ui-i18n-dynamic-language-sets.md`).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
INDEX_PATH = Path(__file__).resolve().parent / "i18n_ui_strings_index.json"
# Dateien mit nutzerrelevanten t()-Aufrufen (erweitern bei weiteren Migrationen)
SCAN_PATHS = [
ROOT / "src/pages/views/trustee/TrusteeAccountingSettingsView.tsx",
ROOT / "src/pages/AutomationsDashboardPage.tsx",
ROOT / "src/pages/billing/BillingDataView.tsx",
ROOT / "src/pages/billing/BillingAdmin.tsx",
ROOT / "src/components/Navigation/MandateNavigation.tsx",
]
def _extractTKeys(source: str) -> set[str]:
"""Extrahiert erste String-Literale aus t('') / t("") (ohne verschachtelte Klammern)."""
keys: set[str] = set()
for m in re.finditer(r"\bt\(\s*((?:'([^'\\]|\\.)*'|\"([^\"\\]|\\.)*\"))\s*(?:,|\))", source):
raw = m.group(1)
if raw.startswith("'"):
inner = raw[1:-1].replace("\\'", "'").replace("\\\\", "\\")
else:
inner = raw[1:-1].replace('\\"', '"').replace("\\\\", "\\")
keys.add(inner)
return keys
def _scanAllKeys() -> dict[str, list[str]]:
fileToKeys: dict[str, list[str]] = {}
for p in SCAN_PATHS:
if not p.exists():
print(f"WARN: fehlt {p}", file=sys.stderr)
continue
text = p.read_text(encoding="utf-8")
keys = sorted(_extractTKeys(text))
rel = str(p.relative_to(ROOT)).replace("\\", "/")
fileToKeys[rel] = keys
return fileToKeys
def _mergeUnique(fileToKeys: dict[str, list[str]]) -> list[str]:
allKeys: set[str] = set()
for ks in fileToKeys.values():
allKeys.update(ks)
return sorted(allKeys)
def main() -> None:
parser = argparse.ArgumentParser(description="i18n-Index aus t()-Aufrufen pflegen.")
parser.add_argument("--write", action="store_true", help="i18n_ui_strings_index.json schreiben")
args = parser.parse_args()
byFile = _scanAllKeys()
merged = _mergeUnique(byFile)
payload = {
"version": 2,
"description": "Extrahierte UI-Keys (Deutsch = Key) für DB/Admin UI-Sprachen; erzeugt durch apply_i18n_from_index.py",
"sourceRoot": "frontend_nyla",
"keysByFile": byFile,
"keysAll": merged,
}
if args.write:
INDEX_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Written {INDEX_PATH} ({len(merged)} unique keys)")
return
if INDEX_PATH.exists():
existing = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
prev = set(existing.get("keysAll", []))
now = set(merged)
if prev != now:
onlyNew = sorted(now - prev)
onlyOld = sorted(prev - now)
print("Index differs from scan. Run with --write to refresh.")
if onlyNew:
print(f" + new ({len(onlyNew)}):", onlyNew[:20], "..." if len(onlyNew) > 20 else "")
if onlyOld:
print(f" - removed ({len(onlyOld)}):", onlyOld[:20], "..." if len(onlyOld) > 20 else "")
sys.exit(1)
print(f"OK: index matches scan ({len(merged)} keys)")
else:
print("No index file; run with --write to create.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

15027
scripts/i18n_missing_report.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,312 +0,0 @@
{
"version": 2,
"description": "Extrahierte UI-Keys (Deutsch = Key) für DB/Admin UI-Sprachen; erzeugt durch apply_i18n_from_index.py",
"sourceRoot": "frontend_nyla",
"keysByFile": {
"src/pages/views/trustee/TrusteeAccountingSettingsView.tsx": [
"Aktueller Datenbestand:",
"Anbindung entfernen",
"Anzeigename",
"Bis (optional)",
"Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.",
"Buchhaltungsanbindung wurde entfernt.",
"Buchhaltungsdaten importieren",
"Buchhaltungseinstellungen werden geladen…",
"Buchhaltungssystem",
"Buchhaltungssystem-Anbindung",
"Cache geleert",
"Cache konnte nicht geleert werden.",
"Daten jetzt einlesen",
"Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.",
"Entfernen",
"Entfernen der Konfiguration fehlgeschlagen.",
"Entfernt",
"Fehler",
"Gespeichert",
"Import abgeschlossen",
"Import abgeschlossen in {sek}s:",
"Import fehlgeschlagen",
"Import teilweise fehlgeschlagen",
"Importiere…",
"KI-Cache leeren",
"Keine Verbindung möglich.",
"Konfiguration speichern",
"Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.",
"Laufendes Jahr",
"Leere…",
"Letzter Import:",
"Letzter Monat",
"Letzter Sync:",
"Letztes Jahr",
"Speichern der Konfiguration fehlgeschlagen.",
"Speichern…",
"Status:",
"System auswählen…",
"Teste…",
"Unbekannter Fehler",
"Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.",
"Verbindung OK",
"Verbindung erfolgreich!",
"Verbindung fehlgeschlagen",
"Verbindung fehlgeschlagen: {message}",
"Verbindung testen",
"Verbindung zum Buchhaltungssystem erfolgreich.",
"Verbindungstest fehlgeschlagen.",
"Verbunden:",
"z. B. Run My Accounts Muster AG",
"{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden",
"{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.",
"{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden",
"{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten."
],
"src/pages/AutomationsDashboardPage.tsx": [
"Aktive Workflows",
"Aktualisieren",
"Automations",
"Beendet",
"Credits gesamt:",
"Download fehlgeschlagen",
"Fehler beim Laden des Automations-Dashboards",
"Gestartet",
"Letzte Runs",
"Mandant",
"Noch keine Workflow-Runs vorhanden.",
"Runs gesamt",
"Status",
"Tokens gesamt:",
"Tracing-Protokoll herunterladen",
"Workflow",
"Workflow-Runs über alle Features und Mandanten",
"Workflows",
"—"
],
"src/pages/billing/BillingDataView.tsx": [
"Alle (RBAC)",
"Anbieter",
"Balken",
"Benutzer",
"Beschreibung",
"Betrag (CHF)",
"Datum",
"Diagramme",
"Feature",
"Fehler beim Laden der Transaktionen",
"Keine Statistiken verfügbar",
"Keine Transaktionen vorhanden",
"Kontext:",
"Mandant",
"Mandant: {name}",
"Meine Kosten",
"Modell",
"Pie",
"Schließen",
"Transaktionen",
"Typ",
"Zahlung abgebrochen.",
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
"nur meine Daten",
"Übersicht"
],
"src/pages/billing/BillingAdmin.tsx": [
"Abonnement",
"Aktiv",
"Auto-Nachladung (KI-Guthaben bei niedrigem Stand)",
"Bei Warnung benachrichtigen",
"Beschreibung",
"Beschreibung der Gutschrift",
"Betrag (CHF)",
"Betrag darf nicht null sein",
"Billing-Einstellungen",
"Bitte wählen Sie einen Mandanten aus.",
"Checkout fehlgeschlagen",
"Deaktiviert",
"Einstellungen",
"Einstellungen speichern",
"Fehler bei der Buchung",
"Guthaben abziehen",
"Guthaben aufladen",
"Guthaben manuell verwalten",
"Guthaben via Stripe aufladen",
"Guthaben:",
"Kein Mandanten-Konto vorhanden",
"Keine Konten vorhanden",
"Konten",
"Lade Konten…",
"Mandant auswählen",
"Mandanten-Konto",
"Manuelle Buchung durch Admin",
"Max. Nachladungen / Monat",
"Mit Stripe bezahlen",
"OK",
"Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.",
"Speichern…",
"Status:",
"Warnschwelle (%)",
"Weiterleitung…",
"Wird verbucht…",
"Zahlung abgebrochen.",
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
"Zahlung erfolgreich. Guthaben wurde verbucht.",
"z.B. 50 oder -20",
"{betrag} erfolgreich abgezogen.",
"{betrag} erfolgreich gutgeschrieben."
],
"src/components/Navigation/MandateNavigation.tsx": [
"Administration",
"Fehler",
"Keine Feature-Instanzen verfügbar.",
"Kontaktiere einen Administrator, um Zugriff zu erhalten.",
"Meine Sicht",
"Navigation wird geladen…",
"Neuer Name:",
"Umbenennen",
"Umbenennung fehlgeschlagen: {detail}"
]
},
"keysAll": [
"Abonnement",
"Administration",
"Aktiv",
"Aktive Workflows",
"Aktualisieren",
"Aktueller Datenbestand:",
"Alle (RBAC)",
"Anbieter",
"Anbindung entfernen",
"Anzeigename",
"Auto-Nachladung (KI-Guthaben bei niedrigem Stand)",
"Automations",
"Balken",
"Beendet",
"Bei Warnung benachrichtigen",
"Benutzer",
"Beschreibung",
"Beschreibung der Gutschrift",
"Betrag (CHF)",
"Betrag darf nicht null sein",
"Billing-Einstellungen",
"Bis (optional)",
"Bitte wählen Sie einen Mandanten aus.",
"Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.",
"Buchhaltungsanbindung wurde entfernt.",
"Buchhaltungsdaten importieren",
"Buchhaltungseinstellungen werden geladen…",
"Buchhaltungssystem",
"Buchhaltungssystem-Anbindung",
"Cache geleert",
"Cache konnte nicht geleert werden.",
"Checkout fehlgeschlagen",
"Credits gesamt:",
"Daten jetzt einlesen",
"Datum",
"Deaktiviert",
"Diagramme",
"Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.",
"Download fehlgeschlagen",
"Einstellungen",
"Einstellungen speichern",
"Entfernen",
"Entfernen der Konfiguration fehlgeschlagen.",
"Entfernt",
"Feature",
"Fehler",
"Fehler bei der Buchung",
"Fehler beim Laden der Transaktionen",
"Fehler beim Laden des Automations-Dashboards",
"Gespeichert",
"Gestartet",
"Guthaben abziehen",
"Guthaben aufladen",
"Guthaben manuell verwalten",
"Guthaben via Stripe aufladen",
"Guthaben:",
"Import abgeschlossen",
"Import abgeschlossen in {sek}s:",
"Import fehlgeschlagen",
"Import teilweise fehlgeschlagen",
"Importiere…",
"KI-Cache leeren",
"Kein Mandanten-Konto vorhanden",
"Keine Feature-Instanzen verfügbar.",
"Keine Konten vorhanden",
"Keine Statistiken verfügbar",
"Keine Transaktionen vorhanden",
"Keine Verbindung möglich.",
"Konfiguration speichern",
"Kontaktiere einen Administrator, um Zugriff zu erhalten.",
"Konten",
"Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.",
"Kontext:",
"Lade Konten…",
"Laufendes Jahr",
"Leere…",
"Letzte Runs",
"Letzter Import:",
"Letzter Monat",
"Letzter Sync:",
"Letztes Jahr",
"Mandant",
"Mandant auswählen",
"Mandant: {name}",
"Mandanten-Konto",
"Manuelle Buchung durch Admin",
"Max. Nachladungen / Monat",
"Meine Kosten",
"Meine Sicht",
"Mit Stripe bezahlen",
"Modell",
"Navigation wird geladen…",
"Neuer Name:",
"Noch keine Workflow-Runs vorhanden.",
"OK",
"Pie",
"Runs gesamt",
"Schließen",
"Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.",
"Speichern der Konfiguration fehlgeschlagen.",
"Speichern…",
"Status",
"Status:",
"System auswählen…",
"Teste…",
"Tokens gesamt:",
"Tracing-Protokoll herunterladen",
"Transaktionen",
"Typ",
"Umbenennen",
"Umbenennung fehlgeschlagen: {detail}",
"Unbekannter Fehler",
"Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.",
"Verbindung OK",
"Verbindung erfolgreich!",
"Verbindung fehlgeschlagen",
"Verbindung fehlgeschlagen: {message}",
"Verbindung testen",
"Verbindung zum Buchhaltungssystem erfolgreich.",
"Verbindungstest fehlgeschlagen.",
"Verbunden:",
"Warnschwelle (%)",
"Weiterleitung…",
"Wird verbucht…",
"Workflow",
"Workflow-Runs über alle Features und Mandanten",
"Workflows",
"Zahlung abgebrochen.",
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
"Zahlung erfolgreich. Guthaben wurde verbucht.",
"nur meine Daten",
"z. B. Run My Accounts Muster AG",
"z.B. 50 oder -20",
"{betrag} erfolgreich abgezogen.",
"{betrag} erfolgreich gutgeschrieben.",
"{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden",
"{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.",
"{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden",
"{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.",
"Übersicht",
"—"
]
}

View file

@ -133,11 +133,11 @@
} }
.table thead tr { .table thead tr {
background: var(--color-bg); background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
} }
.th { .th {
background: var(--color-bg, #f8fafc); background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
@ -148,7 +148,7 @@
white-space: nowrap; white-space: nowrap;
overflow: visible; overflow: visible;
user-select: none; user-select: none;
border-bottom: 1px solid var(--color-border, #e2e8f0); border-bottom: 2px solid var(--color-border, #e2e8f0);
} }
.th.actionsColumn { .th.actionsColumn {
@ -161,7 +161,7 @@
} }
.th.sortable:hover { .th.sortable:hover {
background: var(--color-gray-disabled, #f1f5f9); background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155); color: var(--color-text, #334155);
} }
@ -380,7 +380,7 @@
} }
thead .selectColumn { thead .selectColumn {
background: var(--color-bg); background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
} }
tbody .selectColumn { tbody .selectColumn {
@ -431,7 +431,7 @@ tbody .selectColumn {
} }
thead .actionsColumn { thead .actionsColumn {
background: var(--color-bg); background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
} }
tbody .actionsColumn { tbody .actionsColumn {
@ -766,8 +766,22 @@ tbody .actionsColumn {
/* Dark theme */ /* Dark theme */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.table thead tr {
background: rgba(255, 255, 255, 0.05);
}
.th {
background: rgba(255, 255, 255, 0.05);
border-bottom-color: rgba(255, 255, 255, 0.12);
}
thead .selectColumn,
thead .actionsColumn {
background: rgba(255, 255, 255, 0.05);
}
.th.sortable:hover { .th.sortable:hover {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.1);
} }
.tr:hover { .tr:hover {

View file

@ -6,7 +6,13 @@ export interface I18nCodeInfo {
label?: string; label?: string;
status?: string; status?: string;
isDefault?: boolean; isDefault?: boolean;
keysCount?: number; entriesCount?: number;
}
interface I18nEntryRaw {
context?: string;
key: string;
value: string;
} }
export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> { export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> {
@ -27,8 +33,21 @@ export const loadLanguage = async (language: Language): Promise<TranslationKeys>
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load language ${code}: ${res.status}`); throw new Error(`Failed to load language ${code}: ${res.status}`);
} }
const data = (await res.json()) as { keys?: TranslationKeys }; const data = await res.json();
return data.keys ?? {};
if (Array.isArray(data.entries)) {
const map: TranslationKeys = {};
for (const e of data.entries as I18nEntryRaw[]) {
if (e.key) map[e.key] = e.value ?? '';
}
return map;
}
if (data.keys && typeof data.keys === 'object') {
return data.keys as TranslationKeys;
}
return {};
}; };
export type { Language, TranslationKeys } from './types'; export type { Language, TranslationKeys } from './types';

View file

@ -439,7 +439,7 @@ const NeutralizationMappingsTab: React.FC = () => {
// ============================================================================= // =============================================================================
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const { currentLanguage, setLanguage, availableLanguages } = useLanguage(); const { currentLanguage, setLanguage, availableLanguages, refreshAvailableLanguages } = useLanguage();
const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser(); const { updateUser } = useUser();
@ -449,6 +449,12 @@ export const SettingsPage: React.FC = () => {
const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false);
const [languageError, setLanguageError] = useState<string | null>(null); const [languageError, setLanguageError] = useState<string | null>(null);
useEffect(() => {
if (availableLanguages.length === 0) {
refreshAvailableLanguages();
}
}, [availableLanguages.length, refreshAvailableLanguages]);
const handleThemeChange = (newTheme: 'light' | 'dark') => { const handleThemeChange = (newTheme: 'light' | 'dark') => {
setTheme(newTheme); setTheme(newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);

View file

@ -2,8 +2,8 @@
* SysAdmin: UI language sets (DB-backed i18n). * SysAdmin: UI language sets (DB-backed i18n).
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaTrash } from 'react-icons/fa'; import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaSync, FaTrash } from 'react-icons/fa';
import api from '../../api'; import api from '../../api';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
@ -14,38 +14,157 @@ type LangRow = {
id: string; id: string;
label: string; label: string;
status: string; status: string;
keysCount: number; entriesCount: number;
is?: boolean; };
type ProgressInfo = {
message: string;
current: number;
total: number;
error?: string;
done?: boolean;
}; };
const _columns: ColumnConfig[] = [ const _columns: ColumnConfig[] = [
{ key: 'id', label: 'Code', type: 'text', sortable: true, filterable: true, width: 90 }, { key: 'id', label: 'Code', type: 'text', sortable: true, filterable: true, width: 90 },
{ key: 'label', label: 'Bezeichnung', type: 'text', sortable: true, filterable: true, width: 160 }, { key: 'label', label: 'Bezeichnung', type: 'text', sortable: true, filterable: true, width: 200 },
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 120 }, { key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 120 },
{ key: 'keysCount', label: 'Keys', type: 'number', sortable: true, width: 90 }, { key: 'entriesCount', label: 'Einträge', type: 'number', sortable: true, width: 100 },
]; ];
const _isoChoices = [ const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
{ value: 'it', label: 'it — Italiano' },
{ value: 'es', label: 'es — Español' }, const _isoChoices: { value: string; label: string }[] = [
{ value: 'pt', label: 'pt — Português' }, { value: 'de', label: 'de — Deutsch' }, { value: 'en', label: 'en — English' },
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
{ value: 'pl', label: 'pl — Polski' }, { value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
{ value: 'cs', label: 'cs — Čeština' }, { value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
{ value: 'sk', label: 'sk — Slovenčina' }, { value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
{ value: 'sv', label: 'sv — Svenska' }, { value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
{ value: 'no', label: 'no — Norsk' }, { value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
{ value: 'da', label: 'da — Dansk' }, { 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 pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
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: 340,
maxWidth: 480,
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
textAlign: 'center',
}}
>
<p style={{ fontWeight: 600, fontSize: '1.05rem', marginBottom: '0.75rem' }}>
{progress.message}
</p>
<div
style={{
width: '100%',
height: 8,
background: 'var(--border-color, #e2e8f0)',
borderRadius: 4,
overflow: 'hidden',
marginBottom: '0.5rem',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: progress.error
? 'var(--error-color, #c53030)'
: 'var(--primary-color, #3182ce)',
borderRadius: 4,
transition: 'width 0.3s ease',
}}
/>
</div>
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
{progress.current} / {progress.total}
{progress.done && !progress.error && ' — fertig'}
</p>
{progress.error && (
<p style={{ color: 'var(--error-color, #c53030)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
{progress.error}
</p>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export const AdminLanguagesPage: React.FC = () => { export const AdminLanguagesPage: React.FC = () => {
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage(); const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const [rows, setRows] = useState<LangRow[]>([]); const [rows, setRows] = useState<LangRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [addCode, setAddCode] = useState('it'); const [addCode, setAddCode] = useState('');
const [addLabel, setAddLabel] = useState('Italiano'); const [progress, setProgress] = useState<ProgressInfo | null>(null);
const busyRef = useRef(false);
const _load = useCallback(async () => { const _load = useCallback(async () => {
try { try {
@ -58,7 +177,7 @@ export const AdminLanguagesPage: React.FC = () => {
id: r.code, id: r.code,
label: r.label || r.code, label: r.label || r.code,
status: r.status || '', status: r.status || '',
keysCount: r.keysCount ?? 0, entriesCount: r.entriesCount ?? 0,
})), })),
); );
} catch (e: any) { } catch (e: any) {
@ -72,35 +191,176 @@ export const AdminLanguagesPage: React.FC = () => {
_load(); _load();
}, [_load]); }, [_load]);
const _updateOne = async (code: string) => { 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<any[]> => {
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 { try {
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`); 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 _load();
await refreshAvailableLanguages(); await refreshAvailableLanguages();
await reloadLanguage(); await reloadLanguage();
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail || e.message); 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;
setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), current: 0, total: 1 });
try {
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
setProgress({ message: t('{lang} aktualisiert.', { lang: label }), 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 bei {lang}', { lang: label }), current: 0, total: 1, error: msg, done: true });
setError(msg);
} finally {
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2000);
} }
}; };
const _updateAll = async () => { const _updateAll = async () => {
const ok = await confirm(t('Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?'), { if (busyRef.current) return;
const ok = await confirm(t('Alle Sprachsets jetzt mit dem Basisset synchronisieren und per KI aktualisieren?'), {
confirmLabel: t('Alle aktualisieren'), confirmLabel: t('Alle aktualisieren'),
cancelLabel: t('Abbrechen'), cancelLabel: t('Abbrechen'),
}); });
if (!ok) return; 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 { try {
await api.put('/api/i18n/sets/update-all'); const entries = await _fetchI18nEntriesFromBundle();
await _load(); await api.put('/api/i18n/sets/sync-xx', { entries });
await refreshAvailableLanguages(); step++;
await reloadLanguage(); setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps });
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail || e.message); 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;
setProgress({
message: t('Aktualisiere {lang}… ({n}/{total})', { lang: label, n: String(step + 1), total: String(totalSteps) }),
current: step,
total: totalSteps,
});
try {
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
} 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) => { const _delete = async (code: string) => {
if (code === 'de') return; if (busyRef.current) return;
if (code === 'xx' || code === 'de') return;
const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), { const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), {
confirmLabel: t('Löschen'), confirmLabel: t('Löschen'),
cancelLabel: t('Abbrechen'), cancelLabel: t('Abbrechen'),
@ -137,19 +397,27 @@ export const AdminLanguagesPage: React.FC = () => {
}; };
const _add = async () => { const _add = async () => {
if (busyRef.current) return;
const code = String(addCode).trim().toLowerCase(); const code = String(addCode).trim().toLowerCase();
const label = String(addLabel).trim(); if (!code) return;
const go = await confirm( const go = await confirm(
t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'), t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'),
{ confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') }, { confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') },
); );
if (!go) return; if (!go) return;
busyRef.current = true;
setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 });
try { try {
await api.post('/api/i18n/sets', { code, label }); 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 _load();
await refreshAvailableLanguages(); await refreshAvailableLanguages();
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail || e.message); 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);
} }
}; };
@ -171,6 +439,7 @@ export const AdminLanguagesPage: React.FC = () => {
}; };
const _importFile = async () => { const _importFile = async () => {
if (busyRef.current) return;
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
@ -182,6 +451,8 @@ export const AdminLanguagesPage: React.FC = () => {
{ confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') }, { confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') },
); );
if (!ok) return; if (!ok) return;
busyRef.current = true;
setProgress({ message: t('Importiere…'), current: 0, total: 1 });
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -190,29 +461,37 @@ export const AdminLanguagesPage: React.FC = () => {
}); });
const d = res.data || {}; const d = res.data || {};
setError(null); 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 _load();
await refreshAvailableLanguages(); await refreshAvailableLanguages();
await reloadLanguage(); await reloadLanguage();
alert(t('Import abgeschlossen: {created} erstellt, {updated} aktualisiert.', {
created: String(d.created?.length ?? 0),
updated: String(d.updated?.length ?? 0),
}));
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail || e.message); 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(); input.click();
}; };
const existingCodes = new Set(rows.map((r) => r.id)); const isBusy = progress !== null;
const addChoices = _isoChoices.filter((c) => !existingCodes.has(c.value));
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem' }}> <div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
<header> <header>
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1> <h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p> <p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
{error && ( {error && !progress && (
<p style={{ color: 'var(--error-color, #c53030)' }}> <p style={{ color: 'var(--error-color, #c53030)' }}>
{error} {error}
</p> </p>
@ -220,13 +499,13 @@ export const AdminLanguagesPage: React.FC = () => {
</header> </header>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
<button type="button" className={styles.primaryButton} onClick={_updateAll}> <button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
{t('Alle aktualisieren')} {t('Alle aktualisieren')}
</button> </button>
<button type="button" className={styles.secondaryButton} onClick={_exportAll}> <button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
<FaFileExport /> {t('Export')} <FaFileExport /> {t('Export')}
</button> </button>
<button type="button" className={styles.secondaryButton} onClick={_importFile}> <button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
<FaFileImport /> {t('Import')} <FaFileImport /> {t('Import')}
</button> </button>
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} /> <span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
@ -235,6 +514,7 @@ export const AdminLanguagesPage: React.FC = () => {
value={addCode} value={addCode}
onChange={(e) => setAddCode(e.target.value)} onChange={(e) => setAddCode(e.target.value)}
style={{ padding: '0.35rem 0.5rem' }} style={{ padding: '0.35rem 0.5rem' }}
disabled={isBusy}
> >
{addChoices.map((c) => ( {addChoices.map((c) => (
<option key={c.value} value={c.value}> <option key={c.value} value={c.value}>
@ -242,18 +522,12 @@ export const AdminLanguagesPage: React.FC = () => {
</option> </option>
))} ))}
</select> </select>
<input <button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
style={{ minWidth: 180, padding: '0.35rem 0.5rem' }}
value={addLabel}
onChange={(e) => setAddLabel(e.target.value)}
placeholder={t('Anzeigename')}
/>
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0}>
{t('Hinzufügen')} {t('Hinzufügen')}
</button> </button>
</div> </div>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable <FormGeneratorTable
data={rows} data={rows}
columns={_columns} columns={_columns}
@ -261,12 +535,19 @@ export const AdminLanguagesPage: React.FC = () => {
pagination={false} pagination={false}
selectable={false} selectable={false}
customActions={[ customActions={[
{
id: 'sync-xx',
title: t('UI-Keys einlesen'),
icon: <FaSync />,
onClick: () => _syncXx(),
visible: (row: LangRow) => row.id === 'xx',
},
{ {
id: 'upd', id: 'upd',
title: t('Aktualisieren'), title: t('Aktualisieren'),
icon: <FaRedo />, icon: <FaRedo />,
onClick: (row: LangRow) => _updateOne(row.id), onClick: (row: LangRow) => _updateOne(row.id),
visible: (row: LangRow) => row.id !== 'de', visible: (row: LangRow) => row.id !== 'xx',
}, },
{ {
id: 'dl', id: 'dl',
@ -279,11 +560,13 @@ export const AdminLanguagesPage: React.FC = () => {
title: t('Löschen'), title: t('Löschen'),
icon: <FaTrash />, icon: <FaTrash />,
onClick: (row: LangRow) => _delete(row.id), onClick: (row: LangRow) => _delete(row.id),
visible: (row: LangRow) => row.id !== 'de', visible: (row: LangRow) => row.id !== 'xx' && row.id !== 'de',
}, },
]} ]}
emptyMessage={t('Keine Einträge')} emptyMessage={t('Keine Einträge')}
/> />
{progress && <_ProgressOverlay progress={progress} />}
</div> </div>
<ConfirmDialog /> <ConfirmDialog />

View file

@ -1,83 +1,258 @@
/** /**
* ToolActivityLog -- Real-time tool call activity display. * ToolActivityLog -- Real-time tool call activity display.
*
* Renders tool calls in a human-readable format:
* - Friendly tool names instead of internal identifiers
* - Args filtered to hide UUIDs/internal codes on success
* - Full details shown on error for debugging
*/ */
import React from 'react'; import React, { useState } from 'react';
import type { ToolActivity } from './useWorkspace'; import type { ToolActivity } from './useWorkspace';
interface ToolActivityLogProps { interface ToolActivityLogProps {
activities: ToolActivity[]; activities: ToolActivity[];
} }
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const _TOOL_LABELS: Record<string, string> = {
browseTable: 'Tabelle durchsuchen',
queryTable: 'Tabelle abfragen',
aggregateTable: 'Tabelle aggregieren',
browseContainer: 'Datei durchsuchen',
readContentObjects: 'Inhalte lesen',
extractContainerItem: 'Element extrahieren',
queryFeatureInstance: 'Feature abfragen',
requestToolbox: 'Toolbox anfordern',
searchDocuments: 'Dokumente suchen',
getFileInfo: 'Datei-Info abrufen',
listFiles: 'Dateien auflisten',
listTables: 'Tabellen auflisten',
getTableSchema: 'Tabellenschema abrufen',
createRecord: 'Datensatz erstellen',
updateRecord: 'Datensatz aktualisieren',
deleteRecord: 'Datensatz löschen',
};
const _HIDDEN_ARG_KEYS = new Set([
'mandateId', 'userId', 'featureInstanceId', 'workflowId', 'sessionId',
]);
function _isInternalValue(v: unknown): boolean {
if (typeof v !== 'string') return false;
if (_UUID_RE.test(v)) return true;
if (v.length > 60 && !v.includes(' ')) return true;
return false;
}
function _formatArgs(args: Record<string, any>, isError: boolean): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(args)) {
if (!isError && _HIDDEN_ARG_KEYS.has(k)) continue;
if (!isError && _isInternalValue(v)) continue;
let display: string;
if (typeof v === 'string') {
display = v.length > 80 ? v.slice(0, 77) + '...' : v;
} else if (typeof v === 'number' || typeof v === 'boolean') {
display = String(v);
} else if (v === null || v === undefined) {
continue;
} else {
const json = JSON.stringify(v);
display = json.length > 80 ? json.slice(0, 77) + '...' : json;
}
parts.push(`${k}: ${display}`);
}
return parts.join(', ') || (isError ? JSON.stringify(args) : '');
}
function _formatResult(result: string): string {
if (!result) return '';
const trimmed = result.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return `${parsed.length} Ergebnisse`;
}
if (typeof parsed === 'object' && parsed !== null) {
const keys = Object.keys(parsed);
if (parsed.count !== undefined) return `${parsed.count} Einträge`;
if (parsed.total !== undefined) return `${parsed.total} Einträge`;
if (parsed.rows && Array.isArray(parsed.rows)) return `${parsed.rows.length} Zeilen`;
if (parsed.data && Array.isArray(parsed.data)) return `${parsed.data.length} Einträge`;
if (parsed.result !== undefined) {
const r = String(parsed.result);
return r.length > 120 ? r.slice(0, 117) + '...' : r;
}
if (keys.length <= 3) {
return keys.map((k) => `${k}: ${String(parsed[k]).slice(0, 40)}`).join(', ');
}
return `Objekt (${keys.length} Felder)`;
}
} catch {
// not valid JSON
}
}
return trimmed.length > 150 ? trimmed.slice(0, 147) + '...' : trimmed;
}
function _getToolLabel(toolName: string): string {
return _TOOL_LABELS[toolName] || toolName;
}
function _getStatusLabel(status: string): string {
switch (status) {
case 'calling': return 'läuft';
case 'success': return 'OK';
case 'error': return 'Fehler';
default: return status;
}
}
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => { export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
if (!activities.length) { if (!activities.length) {
return ( return (
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}> <div style={{ padding: 16, textAlign: 'center', color: 'var(--color-text-secondary, #999)', fontSize: 12 }}>
No tool activity yet Noch keine Aktivität
</div> </div>
); );
} }
return ( return (
<div style={{ padding: 8 }}> <div style={{ padding: 8 }}>
{activities.map(activity => ( {activities.map(activity => {
<div const isError = activity.status === 'error';
key={activity.id} const isExpanded = expandedId === activity.id;
style={{ const friendlyName = _getToolLabel(activity.toolName);
padding: '8px 10px', const argsText = activity.args && Object.keys(activity.args).length > 0
marginBottom: 6, ? _formatArgs(activity.args, isError)
borderRadius: 6, : '';
fontSize: 12, const resultText = activity.result ? _formatResult(activity.result) : '';
border: `1px solid ${
activity.status === 'calling' return (
? '#ffc107' <div
: activity.status === 'success' key={activity.id}
? '#4caf50' style={{
: '#f44336' padding: '8px 10px',
}30`, marginBottom: 6,
background: activity.status === 'calling' borderRadius: 6,
? '#fff8e1' fontSize: 12,
: activity.status === 'success' border: `1px solid ${
? '#e8f5e9' activity.status === 'calling'
: '#ffebee', ? 'var(--color-warning, #ffc107)'
}} : activity.status === 'success'
> ? 'var(--color-success, #4caf50)'
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> : 'var(--color-error, #f44336)'
<span style={{ fontWeight: 600 }}>{activity.toolName}</span> }30`,
<span style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 3,
background: activity.status === 'calling' background: activity.status === 'calling'
? '#ffc107' ? 'rgba(255, 193, 7, 0.08)'
: activity.status === 'success' : activity.status === 'success'
? '#4caf50' ? 'rgba(76, 175, 80, 0.06)'
: '#f44336', : 'rgba(244, 67, 54, 0.06)',
color: '#fff', cursor: 'pointer',
}}> transition: 'background 0.15s ease',
{activity.status} }}
</span> onClick={() => setExpandedId(isExpanded ? null : activity.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600 }}>
{friendlyName}
{friendlyName !== activity.toolName && (
<span style={{ fontWeight: 400, opacity: 0.5, marginLeft: 6, fontSize: 10 }}>
{activity.toolName}
</span>
)}
</span>
<span style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 3,
background: activity.status === 'calling'
? 'var(--color-warning, #ffc107)'
: activity.status === 'success'
? 'var(--color-success, #4caf50)'
: 'var(--color-error, #f44336)',
color: '#fff',
}}>
{_getStatusLabel(activity.status)}
</span>
</div>
{argsText && (
<div style={{ marginTop: 4, color: 'var(--color-text-secondary, #666)', fontSize: 11 }}>
{argsText}
</div>
)}
{resultText && !isError && (
<div style={{
marginTop: 4,
color: 'var(--color-success, #388e3c)',
fontSize: 11,
maxHeight: isExpanded ? 'none' : 40,
overflow: 'hidden',
}}>
{resultText}
</div>
)}
{activity.error && (
<div style={{ marginTop: 4, color: 'var(--color-error, #c62828)', fontSize: 11 }}>
{activity.error}
</div>
)}
{isExpanded && activity.args && Object.keys(activity.args).length > 0 && (
<details open style={{ marginTop: 6 }}>
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
Alle Parameter
</summary>
<pre style={{
fontSize: 10,
margin: '4px 0 0',
padding: 6,
background: 'rgba(0,0,0,0.04)',
borderRadius: 4,
overflow: 'auto',
maxHeight: 200,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{JSON.stringify(activity.args, null, 2)}
</pre>
</details>
)}
{isExpanded && activity.result && (
<details open style={{ marginTop: 4 }}>
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
Vollständiges Ergebnis
</summary>
<pre style={{
fontSize: 10,
margin: '4px 0 0',
padding: 6,
background: 'rgba(0,0,0,0.04)',
borderRadius: 4,
overflow: 'auto',
maxHeight: 300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{activity.result}
</pre>
</details>
)}
</div> </div>
{activity.args && Object.keys(activity.args).length > 0 && ( );
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}> })}
{Object.entries(activity.args)
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
.join(', ')}
</div>
)}
{activity.result && (
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
{activity.result.slice(0, 200)}
{activity.result.length > 200 && '...'}
</div>
)}
{activity.error && (
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
{activity.error}
</div>
)}
</div>
))}
</div> </div>
); );
}; };

View file

@ -26,55 +26,42 @@ interface LanguageProviderProps {
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => { export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
const [currentLanguage, setCurrentLanguage] = useState<Language>('de'); const [currentLanguage, setCurrentLanguage] = useState<Language>('de');
const [translations, setTranslations] = useState<TranslationKeys>({}); const [translations, setTranslations] = useState<TranslationKeys>({});
const [deTranslations, setDeTranslations] = useState<TranslationKeys>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]); const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]);
// Function to load and set a language
const loadAndSetLanguage = async (language: Language) => { const loadAndSetLanguage = async (language: Language) => {
setIsLoading(true); setIsLoading(true);
try { try {
const deKeys = await loadLanguage('de'); const targetKeys = await loadLanguage(language);
setDeTranslations(deKeys);
const targetKeys =
language === 'de' ? deKeys : await loadLanguage(language);
setTranslations(targetKeys); setTranslations(targetKeys);
setCurrentLanguage(language); setCurrentLanguage(language);
} catch (error) { } catch (error) {
console.error('Failed to load language:', error); console.error('Failed to load language:', error);
// Keep current language and translations if loading fails
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Load language from user profile on mount
useEffect(() => { useEffect(() => {
const initializeLanguage = async () => { const initializeLanguage = async () => {
let initialLanguage: Language = 'de'; let initialLanguage: Language = 'de';
// Priority 1: Check if user data has language setting (ONLY source of truth!)
const userData = getUserDataCache(); const userData = getUserDataCache();
if (userData?.language && String(userData.language).trim()) { if (userData?.language && String(userData.language).trim()) {
initialLanguage = String(userData.language).trim() as Language; initialLanguage = String(userData.language).trim() as Language;
console.log('🌍 Using language from user profile (sessionStorage cache):', initialLanguage);
await loadAndSetLanguage(initialLanguage); await loadAndSetLanguage(initialLanguage);
return; return;
} }
// Priority 2: Detect browser language (fallback only if no user data)
const browserLang = navigator.language.split('-')[0] as Language; const browserLang = navigator.language.split('-')[0] as Language;
try { try {
const codes = await fetchAvailableLanguageCodes(); const codes = await fetchAvailableLanguageCodes();
const codeSet = new Set(codes.map((c) => c.code)); const codeSet = new Set(codes.map((c) => c.code));
if (codeSet.has(browserLang)) { if (codeSet.has(browserLang) && browserLang !== 'xx') {
initialLanguage = browserLang; initialLanguage = browserLang;
console.log('🌍 Using browser language as fallback:', initialLanguage);
} else {
console.log('🌍 Using default language:', initialLanguage);
} }
} catch { } catch {
console.log('🌍 Using default language:', initialLanguage); // keep default
} }
await loadAndSetLanguage(initialLanguage); await loadAndSetLanguage(initialLanguage);
@ -82,19 +69,22 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
initializeLanguage(); initializeLanguage();
// Listen for user data updates to sync language const handleUserUpdate = async () => {
const handleUserUpdate = () => {
const userData = getUserDataCache(); const userData = getUserDataCache();
if (userData?.language && String(userData.language).trim()) { if (userData?.language && String(userData.language).trim()) {
const userLanguage = String(userData.language).trim() as Language; const userLanguage = String(userData.language).trim() as Language;
if (userLanguage !== currentLanguage) { if (userLanguage !== currentLanguage) {
console.log('🔄 Syncing language with user data (sessionStorage cache):', userLanguage);
loadAndSetLanguage(userLanguage); loadAndSetLanguage(userLanguage);
} }
} }
try {
const list = await fetchAvailableLanguageCodes();
setAvailableLanguages(list.filter((l) => l.code !== 'xx'));
} catch {
// silent
}
}; };
// Listen for user info update events
window.addEventListener('userInfoUpdated', handleUserUpdate); window.addEventListener('userInfoUpdated', handleUserUpdate);
return () => { return () => {
@ -103,15 +93,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
}, []); }, []);
const setLanguage = async (language: Language) => { const setLanguage = async (language: Language) => {
// Load the new language immediately for UI
await loadAndSetLanguage(language); await loadAndSetLanguage(language);
// IMPORTANT: This should ONLY be called after the backend profile is updated
// The settings component should:
// 1. Update backend user profile with new language
// 2. Refetch user data (which includes the new language)
// 3. Update sessionStorage cache with new data (via setUserDataCache)
// 4. Call this function to sync the UI
}; };
const reloadLanguage = async () => { const reloadLanguage = async () => {
@ -121,7 +103,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
const refreshAvailableLanguages = useCallback(async () => { const refreshAvailableLanguages = useCallback(async () => {
try { try {
const list = await fetchAvailableLanguageCodes(); const list = await fetchAvailableLanguageCodes();
setAvailableLanguages(list); setAvailableLanguages(list.filter((l) => l.code !== 'xx'));
} catch (e) { } catch (e) {
console.error('Failed to load language codes:', e); console.error('Failed to load language codes:', e);
} }
@ -151,7 +133,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
const resolved = const resolved =
translations[key] ?? translations[key] ??
deTranslations[key] ??
(typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ?? (typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
`[${key}]`; `[${key}]`;
return _applyParams(resolved, params); return _applyParams(resolved, params);
@ -180,4 +161,4 @@ export const useLanguage = (): LanguageContextType => {
throw new Error('useLanguage must be used within a LanguageProvider'); throw new Error('useLanguage must be used within a LanguageProvider');
} }
return context; return context;
}; };

View file

@ -1,9 +1,97 @@
import { defineConfig, loadEnv, Plugin } from 'vite'; import { defineConfig, loadEnv, Plugin } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { createHtmlPlugin } from 'vite-plugin-html'; import { createHtmlPlugin } from 'vite-plugin-html';
import type { IncomingMessage, ServerResponse } from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
type I18nEntry = { context: string; key: string; value: string };
/** Find the nearest enclosing function/component name above a given character offset. */
function _findEnclosingComponent(content: string, charOffset: number): string {
const reFunc = /(?:export\s+)?(?:const|function)\s+([A-Z_]\w*)/g;
let best = '';
let m: RegExpExecArray | null;
while ((m = reFunc.exec(content)) !== null) {
if (m.index > charOffset) break;
best = m[1];
}
return best;
}
/** Scan all .ts/.tsx under srcRoot for t() calls and return structured entries with context. */
function _scanTKeys(srcRoot: string): I18nEntry[] {
const keyMap = new Map<string, Set<string>>();
const reSingle = /\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))/g;
const reDouble = /\bt\(\s*"((?:\\.|[^"])+)"\s*(?:,|\))/g;
const _addKey = (key: string, relFile: string, content: string, offset: number): void => {
const comp = _findEnclosingComponent(content, offset);
const ctx = comp ? `${relFile} > ${comp}` : relFile;
if (!keyMap.has(key)) keyMap.set(key, new Set());
keyMap.get(key)!.add(ctx);
};
const walk = (dir: string): void => {
if (!fs.existsSync(dir)) return;
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
walk(full);
} else if (/\.(tsx?)$/i.test(ent.name)) {
const content = fs.readFileSync(full, 'utf-8');
const relFile = path.relative(srcRoot, full).replace(/\\/g, '/');
let m: RegExpExecArray | null;
reSingle.lastIndex = 0;
while ((m = reSingle.exec(content)) !== null) {
const raw = m[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\');
if (raw) _addKey(raw, relFile, content, m.index);
}
reDouble.lastIndex = 0;
while ((m = reDouble.exec(content)) !== null) {
const raw = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
if (raw) _addKey(raw, relFile, content, m.index);
}
}
}
};
walk(srcRoot);
const entries: I18nEntry[] = [];
for (const [key, contexts] of [...keyMap.entries()].sort((a, b) => a[0].localeCompare(b[0], 'de'))) {
entries.push({ context: 'ui', key, value: [...contexts].sort().join(' | ') });
}
return entries;
}
function extractI18nKeys(): Plugin {
return {
name: 'extract-i18n-keys',
configureServer(server) {
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
const url = req.url?.split('?')[0] ?? '';
if (!url.endsWith('/i18n-keys.json')) {
next();
return;
}
const srcDir = path.join(process.cwd(), 'src');
const payload = JSON.stringify(_scanTKeys(srcDir), null, 2);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(payload);
});
},
writeBundle(options) {
const outDir = options.dir ?? path.resolve(process.cwd(), 'dist');
const srcDir = path.join(process.cwd(), 'src');
const entries = _scanTKeys(srcDir);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
fs.writeFileSync(path.join(outDir, 'i18n-keys.json'), `${JSON.stringify(entries)}\n`, 'utf-8');
},
};
}
// Custom plugin to serve static HTML files from public directory BEFORE SPA fallback // Custom plugin to serve static HTML files from public directory BEFORE SPA fallback
function serveStaticHtml(): Plugin { function serveStaticHtml(): Plugin {
return { return {
@ -37,6 +125,7 @@ export default defineConfig(({ mode }) => {
plugins: [ plugins: [
// Serve static HTML files // Serve static HTML files
serveStaticHtml(), serveStaticHtml(),
extractI18nKeys(),
react(), react(),
createHtmlPlugin({ createHtmlPlugin({
// Only process main index.html, not public static files // Only process main index.html, not public static files