cleaned sql and ui language sets
This commit is contained in:
parent
280d2d0193
commit
db64505915
56 changed files with 2842 additions and 4235 deletions
112
scripts/apply_i18n_from_index.py
Normal file
112
scripts/apply_i18n_from_index.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/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()
|
||||
312
scripts/i18n_ui_strings_index.json
Normal file
312
scripts/i18n_ui_strings_index.json
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
{
|
||||
"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",
|
||||
"—"
|
||||
]
|
||||
}
|
||||
12
src/App.tsx
12
src/App.tsx
|
|
@ -38,10 +38,11 @@ import { SettingsPage } from './pages/Settings';
|
|||
import { GDPRPage } from './pages/GDPR';
|
||||
import StorePage from './pages/Store';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage } from './pages/admin';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminLanguagesPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -114,8 +115,14 @@ function App() {
|
|||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/transactions" replace />} />
|
||||
<Route path="transactions" element={<BillingDataView />} />
|
||||
<Route path="admin" element={<BillingAdmin />} />
|
||||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* AUTOMATIONS DASHBOARD */}
|
||||
{/* ============================================== */}
|
||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||
|
|
@ -191,11 +198,12 @@ function App() {
|
|||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing">
|
||||
<Route index element={<BillingAdmin />} />
|
||||
<Route index element={<Navigate to="/billing/admin" replace />} />
|
||||
<Route path="mandates" element={<BillingMandateView />} />
|
||||
</Route>
|
||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="languages" element={<AdminLanguagesPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,32 @@ export interface NodeTypeParameter {
|
|||
required?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
frontendType?: string;
|
||||
frontendOptions?: Record<string, unknown>;
|
||||
options?: unknown[];
|
||||
validation?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PortField {
|
||||
name: string;
|
||||
type: string;
|
||||
description: Record<string, string>;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface PortSchema {
|
||||
name: string;
|
||||
fields: PortField[];
|
||||
}
|
||||
|
||||
export interface InputPortDef {
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
export interface OutputPortDef {
|
||||
schema: string;
|
||||
dynamic?: boolean;
|
||||
deriveFrom?: string;
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
|
|
@ -27,9 +53,10 @@ export interface NodeType {
|
|||
parameters: NodeTypeParameter[];
|
||||
inputs: number;
|
||||
outputs: number;
|
||||
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
|
||||
outputLabels?: string[];
|
||||
executor: string;
|
||||
inputPorts?: Record<number, InputPortDef>;
|
||||
outputPorts?: Record<number, OutputPortDef>;
|
||||
meta?: {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
|
|
@ -43,9 +70,16 @@ export interface NodeTypeCategory {
|
|||
label: Record<string, string> | string;
|
||||
}
|
||||
|
||||
export interface SystemVariable {
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NodeTypesResponse {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
}
|
||||
|
||||
export interface Automation2GraphNode {
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export function ContentPreview({
|
|||
const actions: PopupAction[] = [
|
||||
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
|
||||
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
|
||||
label: copySuccess ? t('files.preview.copied', 'Copied!') : t(''),
|
||||
label: copySuccess ? t('In die Zwischenablage kopiert') : t(''),
|
||||
icon: copySuccess ? '✓' : <IoIosCopy />,
|
||||
onClick: handleCopyContent,
|
||||
disabled: !previewContent && !previewUrl,
|
||||
|
|
@ -292,7 +292,7 @@ export function ContentPreview({
|
|||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ export function UrlContentPreview({
|
|||
}}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenInNewTab}
|
||||
|
|
@ -329,7 +329,7 @@ export function UrlContentPreview({
|
|||
<Popup
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`}
|
||||
title={`${t('Dateivorschau')}: ${fileName}`}
|
||||
size="fullscreen"
|
||||
className={styles.contentPreviewPopup}
|
||||
actions={actions}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
|
|||
onClick={onRetry}
|
||||
className={styles.retryButton}
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
{t('Wiederholen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('files.preview.json.properties', 'properties')}</span>
|
||||
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('Eigenschaften')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderTable(preprocessedData, 0, 'root')}
|
||||
|
|
@ -488,14 +488,14 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
|
|||
return (
|
||||
<div className={styles.jsonContainer}>
|
||||
<div className={styles.jsonHeader}>
|
||||
<span className={styles.jsonTitle}>{t('files.preview.json.invalid', 'Raw Content (Invalid JSON)')}: {fileName}</span>
|
||||
<span className={styles.jsonTitle}>{t('Ungültiges JSON')}: {fileName}</span>
|
||||
<div className={styles.jsonHeaderRight}>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopyJson}
|
||||
title={t('files.preview.json.copyRaw', 'Copy content to clipboard')}
|
||||
title={t('Rohtext in die Zwischenablage kopieren')}
|
||||
>
|
||||
📋 {t('common.copy', 'Copy')}
|
||||
📋 {t('Kopieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export function LoadingRenderer() {
|
|||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner}></div>
|
||||
<p>{t('files.preview.loading', 'Loading preview...')}</p>
|
||||
<p>{t('Vorschau wird geladen...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
|
|||
<div className={styles.warningMessage}>
|
||||
<span className={styles.warningIcon}><IoIosWarning /></span>
|
||||
<span className={styles.warningText}>
|
||||
{t('files.preview.pdfFileCorrupted', 'This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible.')}
|
||||
{t('Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
|
|||
return (
|
||||
<div className={styles.unsupportedContainer}>
|
||||
<div className={styles.unsupportedIcon}>📄</div>
|
||||
<p>{t('files.preview.unsupported', 'Preview not available for this file type')}</p>
|
||||
<p>{t('Vorschau für diesen Dateityp nicht verfügbar')}</p>
|
||||
<p className={styles.fileName}>{fileName}</p>
|
||||
<a
|
||||
href={previewUrl}
|
||||
download={fileName}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
{t('files.action.download', 'Download')}
|
||||
{t('Herunterladen')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
||||
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
|
||||
export interface Automation2DataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
|
|
@ -14,6 +15,8 @@ export interface Automation2DataFlowContextValue {
|
|||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
systemVariables: Record<string, SystemVariable>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
}
|
||||
|
|
@ -31,6 +34,8 @@ interface Automation2DataFlowProviderProps {
|
|||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +46,8 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||
|
|
@ -52,11 +59,13 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
|||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog,
|
||||
systemVariables,
|
||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
};
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language]);
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
||||
|
||||
return (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
syncCanvasStartNode,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
|
|
@ -88,6 +88,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
||||
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
|
@ -166,8 +168,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
|
||||
const nodeOutputsPreview = useMemo(
|
||||
() =>
|
||||
buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
|
||||
[canvasNodes, executeResult?.nodeOutputs]
|
||||
buildNodeOutputsPreview(canvasNodes, nodeTypes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
|
||||
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
||||
);
|
||||
|
||||
const applyGraphWithSync = useCallback(
|
||||
|
|
@ -353,6 +355,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
const data = await fetchNodeTypes(request, instanceId, language);
|
||||
setNodeTypes(data.nodeTypes);
|
||||
setCategories(data.categories);
|
||||
if (data.portTypeCatalog) {
|
||||
setPortTypeCatalog(data.portTypeCatalog);
|
||||
setRegistryCatalog(data.portTypeCatalog as never);
|
||||
}
|
||||
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setNodeTypes([]);
|
||||
|
|
@ -720,6 +727,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
nodeOutputsPreview={nodeOutputsPreview}
|
||||
nodeTypes={nodeTypes}
|
||||
language={language}
|
||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||
systemVariables={systemVariables as Record<string, never>}
|
||||
>
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,30 @@ const NODE_HEIGHT = 72;
|
|||
const HANDLE_SIZE = 12;
|
||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||
|
||||
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||
function _checkConnectionCompatibility(
|
||||
sourceNode: CanvasNode,
|
||||
sourceOutputIdx: number,
|
||||
targetNode: CanvasNode,
|
||||
targetInputIdx: number,
|
||||
nodeTypes: NodeType[],
|
||||
): 'ok' | 'warning' {
|
||||
const srcType = nodeTypes.find((nt) => nt.id === sourceNode.type);
|
||||
const tgtType = nodeTypes.find((nt) => nt.id === targetNode.type);
|
||||
if (!srcType?.outputPorts || !tgtType?.inputPorts) return 'ok';
|
||||
|
||||
const srcPort = srcType.outputPorts[sourceOutputIdx];
|
||||
const tgtPort = tgtType.inputPorts[targetInputIdx];
|
||||
if (!srcPort || !tgtPort) return 'ok';
|
||||
|
||||
const srcSchema = srcPort.schema;
|
||||
const accepts = tgtPort.accepts;
|
||||
if (!accepts || accepts.length === 0) return 'ok';
|
||||
if (accepts.includes('Transit')) return 'ok';
|
||||
if (accepts.includes(srcSchema)) return 'ok';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
interface FlowCanvasProps {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
|
|
@ -70,6 +94,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
||||
const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
|
||||
const [selectionBox, setSelectionBox] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
|
|
@ -245,6 +270,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
targetId: targetNodeId,
|
||||
targetHandle: targetHandleIndex,
|
||||
};
|
||||
|
||||
const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId);
|
||||
const tgtNode = nodes.find((n) => n.id === targetNodeId);
|
||||
if (srcNode && tgtNode) {
|
||||
const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs
|
||||
? connectingFrom.handleIndex - srcNode.inputs : 0;
|
||||
const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes);
|
||||
if (compat === 'warning') {
|
||||
setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionsChange([...connections, newConn]);
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
|
|
@ -568,6 +605,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowhead-warning"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#FF9800" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connections.map((c) => {
|
||||
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
||||
|
|
@ -578,6 +625,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
const dx = tgt.x - src.x;
|
||||
const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
|
||||
const isSelected = selectedConnectionId === c.id;
|
||||
const isWarning = connectionWarnings[c.id];
|
||||
const strokeColor = isSelected
|
||||
? 'var(--primary-color, #007bff)'
|
||||
: isWarning
|
||||
? '#FF9800'
|
||||
: 'var(--text-secondary, #666)';
|
||||
return (
|
||||
<g
|
||||
key={c.id}
|
||||
|
|
@ -597,11 +650,15 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
|||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
|
||||
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{isWarning && !isSelected && (
|
||||
<title>Type mismatch warning: output type may not match input type</title>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
|
||||
* Delegates to config components from nodes/configs.
|
||||
* NodeConfigPanel - Generic parameter renderer for all node types.
|
||||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType } from '../../../api/workflowApi';
|
||||
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
interface NodeConfigPanelProps {
|
||||
|
|
@ -22,8 +22,6 @@ interface NodeConfigPanelProps {
|
|||
request?: ApiRequestFunction;
|
||||
}
|
||||
|
||||
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.', 'trustee.'];
|
||||
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||
node,
|
||||
nodeType,
|
||||
|
|
@ -52,7 +50,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
};
|
||||
}, [node?.id]);
|
||||
|
||||
/** Do not call onParametersChange (parent setState) inside setParams updater — React forbids updating a parent during a child's state update. */
|
||||
const updateParam = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setParams((prev) => {
|
||||
|
|
@ -73,21 +70,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
[onParametersChange]
|
||||
);
|
||||
|
||||
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
|
||||
if (!node || !isConfigurable) return null;
|
||||
|
||||
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
|
||||
if (!ConfigRenderer) {
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||
<p>No configuration for {node.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!node || !nodeType) return null;
|
||||
|
||||
const isTrigger = node.type.startsWith('trigger.');
|
||||
const showNameField = onNodeUpdate && !isTrigger;
|
||||
const parameters = nodeType.parameters || [];
|
||||
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
|
|
@ -112,14 +99,22 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
|||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
<ConfigRenderer
|
||||
params={params}
|
||||
updateParam={updateParam}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
mergeNodeParameters={onMergeNodeParameters}
|
||||
/>
|
||||
{parameters.map((param: NodeTypeParameter) => {
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
return (
|
||||
<Renderer
|
||||
key={param.name}
|
||||
param={param}
|
||||
value={params[param.name] ?? param.default}
|
||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||
allParams={params}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
405
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
Normal file
405
src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
/**
|
||||
* Generic FrontendType renderer registry.
|
||||
* Maps frontendType strings to React components.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||
|
||||
export interface FieldRendererProps {
|
||||
param: NodeTypeParameter;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
allParams?: Record<string, unknown>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline renderers for standard types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.name}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TextareaInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<textarea
|
||||
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.name}
|
||||
rows={4}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NumberInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === 'number' ? value : (value != null ? Number(value) || 0 : '')}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CheckboxInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<label style={{ fontSize: 12 }}>{param.description || param.name}</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const options: string[] =
|
||||
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const options: string[] =
|
||||
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const toggle = (opt: string) => {
|
||||
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JsonEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const strVal = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2);
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<textarea
|
||||
value={strVal}
|
||||
onChange={(e) => {
|
||||
try { onChange(JSON.parse(e.target.value)); } catch { onChange(e.target.value); }
|
||||
}}
|
||||
rows={6}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace', fontSize: 11, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HiddenInput: React.FC<FieldRendererProps> = () => null;
|
||||
|
||||
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
|
||||
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
|
||||
React.useEffect(() => {
|
||||
if (!instanceId || !request) return;
|
||||
request(`/api/graphicalEditor/${instanceId}/options/user.connection`)
|
||||
.then((res: unknown) => {
|
||||
const data = res as { options?: Array<{ value: string; label: string }> };
|
||||
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [instanceId, request]);
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<select
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">— Select connection —</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
|
||||
const dependsOn = param.frontendOptions?.dependsOn as string | undefined;
|
||||
const depValue = dependsOn ? allParams?.[dependsOn] : undefined;
|
||||
const disabled = dependsOn && !depValue;
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={!!disabled}
|
||||
placeholder={disabled ? `Select ${dependsOn} first` : param.name}
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: disabled ? 0.5 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const cases = Array.isArray(value) ? value : [];
|
||||
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
|
||||
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateCase = (idx: number, field: string, val: unknown) => {
|
||||
const next = [...cases];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{cases.map((c: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">equals</option>
|
||||
<option value="neq">not equals</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="gt">greater than</option>
|
||||
<option value="lt">less than</option>
|
||||
</select>
|
||||
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>+ Add case</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const fields = Array.isArray(value) ? value : [];
|
||||
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
|
||||
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateField = (idx: number, field: string, val: unknown) => {
|
||||
const next = [...fields];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{fields.map((f: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
||||
<input type="text" placeholder="Name" value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="checkbox">Checkbox</option>
|
||||
<option value="select">Select</option>
|
||||
<option value="textarea">Textarea</option>
|
||||
</select>
|
||||
<input type="text" placeholder="Label" value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> Req
|
||||
</label>
|
||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>+ Add field</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
const addRow = () => onChange([...rows, { key: '', value: '' }]);
|
||||
const removeRow = (idx: number) => onChange(rows.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateRow = (idx: number, field: string, val: string) => {
|
||||
const next = [...rows];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{rows.map((r: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input type="text" placeholder="Key" value={String(r.key ?? r.fieldKey ?? '')} onChange={(e) => updateRow(i, 'key', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<input type="text" placeholder="Value" value={String(r.value ?? '')} onChange={(e) => updateRow(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeRow(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addRow} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>+ Add row</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="*/5 * * * *"
|
||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
|
||||
/>
|
||||
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>Cron: min hour day month weekday</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
|
||||
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">equals</option>
|
||||
<option value="neq">not equals</option>
|
||||
<option value="gt">greater than</option>
|
||||
<option value="lt">less than</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="empty">is empty</option>
|
||||
<option value="not_empty">is not empty</option>
|
||||
<option value="is_true">is true</option>
|
||||
<option value="is_false">is false</option>
|
||||
</select>
|
||||
<input type="text" placeholder="Value" value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const mappings = Array.isArray(value) ? value : [];
|
||||
const addMapping = () => onChange([...mappings, { sourceField: '', outputField: '' }]);
|
||||
const removeMapping = (idx: number) => onChange(mappings.filter((_: unknown, i: number) => i !== idx));
|
||||
const updateMapping = (idx: number, field: string, val: string) => {
|
||||
const next = [...mappings];
|
||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||
onChange(next);
|
||||
};
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
{mappings.map((m: Record<string, unknown>, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input type="text" placeholder="Source field" value={String(m.sourceField ?? '')} onChange={(e) => updateMapping(i, 'sourceField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<span style={{ alignSelf: 'center' }}>→</span>
|
||||
<input type="text" placeholder="Output field" value={String(m.outputField ?? '')} onChange={(e) => updateMapping(i, 'outputField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<button onClick={() => removeMapping(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addMapping} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>+ Add mapping</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
|
||||
const update = (field: string, val: unknown) => onChange({ ...cond, [field]: val });
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" placeholder="Field" value={String(cond.field ?? '')} onChange={(e) => update('field', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
||||
<option value="eq">equals</option>
|
||||
<option value="neq">not equals</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="startsWith">starts with</option>
|
||||
<option value="isEmpty">is empty</option>
|
||||
<option value="isNotEmpty">is not empty</option>
|
||||
<option value="gt">greater than</option>
|
||||
<option value="lt">less than</option>
|
||||
</select>
|
||||
<input type="text" placeholder="Value" value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||
text: TextInput,
|
||||
textarea: TextareaInput,
|
||||
number: NumberInput,
|
||||
checkbox: CheckboxInput,
|
||||
date: DateInput,
|
||||
datetime: DateInput,
|
||||
email: TextInput,
|
||||
select: SelectInput,
|
||||
multiselect: MultiSelectInput,
|
||||
json: JsonEditor,
|
||||
file: TextInput,
|
||||
hidden: HiddenInput,
|
||||
userConnection: ConnectionPicker,
|
||||
sharepointFolder: FolderPicker,
|
||||
sharepointFile: FolderPicker,
|
||||
clickupList: FolderPicker,
|
||||
clickupTask: FolderPicker,
|
||||
caseList: CaseListEditor,
|
||||
fieldBuilder: FieldBuilderEditor,
|
||||
keyValueRows: KeyValueRowsEditor,
|
||||
cron: CronBuilder,
|
||||
condition: ConditionBuilder,
|
||||
mappingTable: MappingTableEditor,
|
||||
filterExpression: FilterExpressionEditor,
|
||||
};
|
||||
|
||||
export default FRONTEND_TYPE_RENDERERS;
|
||||
|
|
@ -1,44 +1,100 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data Picker for selecting node output references.
|
||||
* Automation2 Flow Editor - Schema-based Data Picker.
|
||||
* Builds pickable paths from portTypeCatalog + node outputPorts.
|
||||
* Resolves Transit chains to show the real upstream schema.
|
||||
* Includes a System Variables section.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createRef, type DataRef } from './dataRef';
|
||||
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
|
||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
interface DataPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (ref: DataRef) => void;
|
||||
onPick: (ref: DataRef | SystemVarRef) => void;
|
||||
availableSourceIds: string[];
|
||||
nodes: Array<{ id: string; title?: string; type?: string }>;
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
getNodeLabel: (node: { id: string; title?: string }) => string;
|
||||
}
|
||||
|
||||
/** Collect all pickable paths (each leads to a value the user can reference) */
|
||||
function buildPickablePaths(obj: unknown, basePath: (string | number)[] = []): Array<{ path: (string | number)[]; label: string }> {
|
||||
interface PickablePath {
|
||||
path: (string | number)[];
|
||||
label: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function _buildPathsFromSchema(
|
||||
schema: PortSchema | undefined,
|
||||
basePath: (string | number)[] = [],
|
||||
): PickablePath[] {
|
||||
if (!schema || !schema.fields) return [];
|
||||
const result: PickablePath[] = [];
|
||||
for (const field of schema.fields) {
|
||||
const fieldPath = [...basePath, field.name];
|
||||
const label = fieldPath.map(String).join(' → ');
|
||||
result.push({ path: fieldPath, label, type: field.type });
|
||||
}
|
||||
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||
return result;
|
||||
}
|
||||
|
||||
function _buildPathsFromPreview(obj: unknown, basePath: (string | number)[] = []): PickablePath[] {
|
||||
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : '(ganze Ausgabe)';
|
||||
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
|
||||
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
||||
result.push(...buildPickablePaths(obj[i], [...basePath, i]));
|
||||
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
|
||||
for (let i = 0; i < Math.min(obj.length, 5); i++) {
|
||||
result.push(..._buildPathsFromPreview(obj[i], [...basePath, i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
|
||||
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result.push(...buildPickablePaths(v, [...basePath, k]));
|
||||
if (k.startsWith('_')) continue;
|
||||
result.push(..._buildPathsFromPreview(v, [...basePath, k]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return [{ path: [...basePath], label: pathLabel }];
|
||||
}
|
||||
|
||||
function _resolveSchemaForNode(
|
||||
nodeId: string,
|
||||
nodes: Array<{ id: string; type?: string }>,
|
||||
nodeTypes: NodeType[],
|
||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||
catalog: Record<string, PortSchema>,
|
||||
visited: Set<string> = new Set(),
|
||||
): PortSchema | undefined {
|
||||
if (visited.has(nodeId)) return undefined;
|
||||
visited.add(nodeId);
|
||||
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return undefined;
|
||||
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
|
||||
if (!typeDef?.outputPorts) return undefined;
|
||||
|
||||
const port0 = typeDef.outputPorts[0];
|
||||
if (!port0) return undefined;
|
||||
|
||||
if (port0.schema !== 'Transit') {
|
||||
return catalog[port0.schema];
|
||||
}
|
||||
|
||||
// Transit: follow the incoming connection to find the real producer
|
||||
const incoming = connections.find((c) => c.target === nodeId);
|
||||
if (!incoming) return undefined;
|
||||
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
|
||||
}
|
||||
|
||||
export const DataPicker: React.FC<DataPickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -49,9 +105,16 @@ export const DataPicker: React.FC<DataPickerProps> = ({
|
|||
getNodeLabel,
|
||||
}) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showSystem, setShowSystem] = useState(false);
|
||||
const ctx = useAutomation2DataFlow();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const catalog = ctx?.portTypeCatalog ?? {};
|
||||
const systemVars = ctx?.systemVariables ?? {};
|
||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||
const connections = ctx?.connections ?? [];
|
||||
|
||||
const toggleExpand = (nodeId: string) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -66,6 +129,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({
|
|||
onClose();
|
||||
};
|
||||
|
||||
const handlePickSystemVar = (variable: string) => {
|
||||
onPick(createSystemVar(variable));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dataPickerOverlay} onClick={onClose}>
|
||||
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -76,21 +144,57 @@ export const DataPicker: React.FC<DataPickerProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.dataPickerBody}>
|
||||
{/* System Variables Section */}
|
||||
{Object.keys(systemVars).length > 0 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dataPickerNodeHeader}
|
||||
onClick={() => setShowSystem(!showSystem)}
|
||||
>
|
||||
<span className={styles.dataPickerExpandIcon}>{showSystem ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>System</span>
|
||||
</button>
|
||||
{showSystem && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{Object.entries(systemVars).map(([key, info]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={styles.dataPickerLeaf}
|
||||
onClick={() => handlePickSystemVar(key)}
|
||||
title={info.description}
|
||||
>
|
||||
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node outputs */}
|
||||
{(() => {
|
||||
const filteredIds = availableSourceIds.filter((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
return node?.type !== 'trigger.manual';
|
||||
});
|
||||
if (filteredIds.length === 0) {
|
||||
if (filteredIds.length === 0 && Object.keys(systemVars).length === 0) {
|
||||
return <p className={styles.dataPickerEmpty}>Keine vorherigen Nodes verfügbar.</p>;
|
||||
}
|
||||
return filteredIds.map((nodeId) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const preview = nodeOutputsPreview[nodeId];
|
||||
const label = node ? getNodeLabel(node) : nodeId;
|
||||
const paths = buildPickablePaths(preview);
|
||||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
const resolvedSchema = _resolveSchemaForNode(
|
||||
nodeId, nodes, nodeTypes, connections, catalog,
|
||||
);
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
|
||||
const paths = schemaPaths.length > 0
|
||||
? schemaPaths
|
||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId]);
|
||||
|
||||
return (
|
||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||
<button
|
||||
|
|
@ -100,6 +204,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({
|
|||
>
|
||||
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className={styles.dataPickerNodeLabel}>{label}</span>
|
||||
{resolvedSchema && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({resolvedSchema.name})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
|
|
@ -111,6 +220,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({
|
|||
onClick={() => handlePick(nodeId, p.path)}
|
||||
>
|
||||
{p.label}
|
||||
{p.type && (
|
||||
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
|
||||
({p.type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,10 +16,25 @@ export interface DataValue {
|
|||
value: unknown;
|
||||
}
|
||||
|
||||
/** Union: either a reference or a static value */
|
||||
export type DynamicValue = DataRef | DataValue;
|
||||
/** System variable reference */
|
||||
export interface SystemVarRef {
|
||||
type: 'system';
|
||||
variable: string;
|
||||
}
|
||||
|
||||
/** Union: reference, static value, or system variable */
|
||||
export type DynamicValue = DataRef | DataValue | SystemVarRef;
|
||||
|
||||
/** Type guards */
|
||||
export function isSystemVar(v: unknown): v is SystemVarRef {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as SystemVarRef).type === 'system' &&
|
||||
typeof (v as SystemVarRef).variable === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isRef(v: unknown): v is DataRef {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
|
|
@ -39,7 +54,12 @@ export function isValue(v: unknown): v is DataValue {
|
|||
}
|
||||
|
||||
export function isDynamicValue(v: unknown): v is DynamicValue {
|
||||
return isRef(v) || isValue(v);
|
||||
return isRef(v) || isValue(v) || isSystemVar(v);
|
||||
}
|
||||
|
||||
/** Create a system variable reference */
|
||||
export function createSystemVar(variable: string): SystemVarRef {
|
||||
return { type: 'system', variable };
|
||||
}
|
||||
|
||||
/** Create a reference object */
|
||||
|
|
|
|||
|
|
@ -1,153 +1,92 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Output preview builders per node type.
|
||||
* Derives example output trees from node parameters for Data Picker.
|
||||
* Extensible: register builders for new node types without changing core logic.
|
||||
* Automation2 Flow Editor - Schema-based output preview builders.
|
||||
* Derives preview trees from portTypeCatalog + node outputPorts.
|
||||
*/
|
||||
|
||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||
import type { PortSchema, NodeType } from '../../../../api/workflowApi';
|
||||
|
||||
export type OutputPreviewBuilder = (node: CanvasNode) => unknown;
|
||||
let _portTypeCatalog: Record<string, PortSchema> = {};
|
||||
|
||||
const builders: Record<string, OutputPreviewBuilder> = {};
|
||||
export function setPortTypeCatalog(catalog: Record<string, PortSchema>): void {
|
||||
_portTypeCatalog = catalog;
|
||||
}
|
||||
|
||||
function parseFormFields(
|
||||
params: Record<string, unknown>
|
||||
): Array<{ name: string; type?: string }> {
|
||||
const raw = params.formFields ?? params.fields;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((f, i) => {
|
||||
if (f && typeof f === 'object' && !Array.isArray(f)) {
|
||||
const o = f as Record<string, unknown>;
|
||||
return {
|
||||
name: String(o.name ?? `field${i + 1}`),
|
||||
type: typeof o.type === 'string' ? o.type : undefined,
|
||||
};
|
||||
function _defaultForType(typeStr: string): unknown {
|
||||
if (typeStr.startsWith('List')) return [];
|
||||
if (typeStr.startsWith('Dict')) return {};
|
||||
if (typeStr === 'bool') return false;
|
||||
if (typeStr === 'int') return 0;
|
||||
if (typeStr === 'str') return '...';
|
||||
if (typeStr === 'Any') return '...';
|
||||
return null;
|
||||
}
|
||||
|
||||
function _buildSchemaPreview(schemaName: string): Record<string, unknown> {
|
||||
const schema = _portTypeCatalog[schemaName];
|
||||
if (!schema || !schema.fields) return {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const field of schema.fields) {
|
||||
result[field.name] = _defaultForType(field.type);
|
||||
}
|
||||
result._success = true;
|
||||
result._error = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function _buildDynamicFormPreview(node: CanvasNode): Record<string, unknown> {
|
||||
const fields = (node.parameters?.fields ?? node.parameters?.formFields) as
|
||||
| Array<{ name?: string; type?: string }>
|
||||
| undefined;
|
||||
if (!Array.isArray(fields)) return { payload: {} };
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f && typeof f === 'object' && f.name) {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
return { name: `field${i + 1}` };
|
||||
});
|
||||
}
|
||||
return { payload, _success: true, _error: null };
|
||||
}
|
||||
|
||||
function runEnvelopeBase(): Record<string, unknown> {
|
||||
return {
|
||||
trigger: { type: 'manual' },
|
||||
payload: {},
|
||||
context: {},
|
||||
files: [],
|
||||
user: {},
|
||||
metadata: {},
|
||||
raw: {},
|
||||
};
|
||||
}
|
||||
/** Build preview for a single node using its outputPorts schema */
|
||||
export function buildNodeOutputPreview(
|
||||
node: CanvasNode,
|
||||
nodeTypeDef?: NodeType
|
||||
): unknown {
|
||||
const outputPorts = nodeTypeDef?.outputPorts;
|
||||
if (!outputPorts) return {};
|
||||
|
||||
/** Register a builder for a node type id (exact match) or prefix (use '*' suffix) */
|
||||
export function registerOutputPreview(typeIdOrPrefix: string, builder: OutputPreviewBuilder): void {
|
||||
builders[typeIdOrPrefix] = builder;
|
||||
}
|
||||
const port0 = outputPorts[0];
|
||||
if (!port0) return {};
|
||||
|
||||
/** Build preview for a single node; returns {} for unknown types */
|
||||
export function buildNodeOutputPreview(node: CanvasNode): unknown {
|
||||
const exact = builders[node.type];
|
||||
if (exact) return exact(node);
|
||||
if (port0.dynamic && port0.deriveFrom) {
|
||||
if (port0.schema === 'FormPayload') {
|
||||
return _buildDynamicFormPreview(node);
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = node.type.split('.')[0];
|
||||
const prefixBuilder = builders[`${prefix}.*`];
|
||||
if (prefixBuilder) return prefixBuilder(node);
|
||||
if (port0.schema === 'Transit') {
|
||||
return { _transit: true, _meta: {}, data: {} };
|
||||
}
|
||||
|
||||
return {};
|
||||
return _buildSchemaPreview(port0.schema);
|
||||
}
|
||||
|
||||
/** Build full nodeOutputsPreview map from graph */
|
||||
export function buildNodeOutputsPreview(
|
||||
nodes: CanvasNode[],
|
||||
nodeTypes: NodeType[],
|
||||
nodeOutputsFromRun?: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const typeMap = new Map(nodeTypes.map((nt) => [nt.id, nt]));
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const n of nodes) {
|
||||
const fromRun = nodeOutputsFromRun?.[n.id];
|
||||
if (fromRun !== undefined) {
|
||||
result[n.id] = fromRun;
|
||||
} else {
|
||||
result[n.id] = buildNodeOutputPreview(n);
|
||||
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Built-in builders (extensible, no hardcoding in core) ----
|
||||
|
||||
registerOutputPreview('trigger.manual', () => runEnvelopeBase());
|
||||
registerOutputPreview('trigger.schedule', () => runEnvelopeBase());
|
||||
|
||||
registerOutputPreview('trigger.form', (node) => {
|
||||
const params = node.parameters ?? {};
|
||||
const fields = parseFormFields(params);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f.type === 'clickup_tasks') {
|
||||
payload[f.name] = { add: ['…'], rem: [] };
|
||||
} else {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
}
|
||||
return { ...runEnvelopeBase(), payload };
|
||||
});
|
||||
|
||||
registerOutputPreview('input.form', (node) => {
|
||||
const params = node.parameters ?? {};
|
||||
const fields = parseFormFields(params);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (f.type === 'clickup_tasks') {
|
||||
payload[f.name] = '';
|
||||
} else {
|
||||
payload[f.name] = '';
|
||||
}
|
||||
}
|
||||
/** Nur payload — kein Spread der Keys nach oben (vermeidet doppelte / verwirrende Pfade im Data Picker). */
|
||||
return { payload };
|
||||
});
|
||||
|
||||
registerOutputPreview('input.upload', () => ({
|
||||
file: { id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' },
|
||||
files: [{ id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' }],
|
||||
fileIds: ['...'],
|
||||
}));
|
||||
|
||||
registerOutputPreview('ai.*', () => ({ prompt: '...', context: '...', result: '...' }));
|
||||
registerOutputPreview('file.*', () => ({
|
||||
documents: [{ documentName: '...', documentData: '...' }],
|
||||
documentList: [{ documentName: '...', documentData: '...' }],
|
||||
}));
|
||||
registerOutputPreview('email.*', () => ({ subject: '...', body: '...' }));
|
||||
registerOutputPreview('email.searchEmail', () => ({
|
||||
data: { searchResults: { results: [{ subject: '...', from: '...' }] } },
|
||||
}));
|
||||
registerOutputPreview('email.checkEmail', () => ({
|
||||
data: { emails: { emails: [{ subject: '...', from: '...' }] } },
|
||||
}));
|
||||
registerOutputPreview('sharepoint.*', () => ({ file: { url: '...', name: '...' } }));
|
||||
registerOutputPreview('sharepoint.listFiles', () => ({
|
||||
files: [{ url: '...', name: '...' }],
|
||||
}));
|
||||
registerOutputPreview('clickup.createTask', () => ({
|
||||
success: true,
|
||||
taskId: '…',
|
||||
clickupTask: { id: '…', name: '…' },
|
||||
documents: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
|
||||
documentList: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
|
||||
}));
|
||||
|
||||
registerOutputPreview('clickup.*', () => ({
|
||||
success: true,
|
||||
taskId: '…',
|
||||
clickupTask: { id: '…' },
|
||||
documents: [{ documentName: 'clickup_result.json', documentData: '{}' }],
|
||||
}));
|
||||
registerOutputPreview('flow.ifElse', () => ({ branch: 0, conditionResult: true, input: {} }));
|
||||
registerOutputPreview('flow.switch', () => ({ match: 0, value: '...' }));
|
||||
registerOutputPreview('flow.loop', () => ({
|
||||
items: [],
|
||||
count: 0,
|
||||
currentItem: { name: 'field', value: '...' },
|
||||
currentIndex: 0,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function CopyActionButton<T = any>({
|
|||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('prompts.action.copy', 'Copy to Clipboard');
|
||||
const buttonTitle = title || t('Kopieren');
|
||||
const isLoading = loading || isCopying || internalLoading;
|
||||
|
||||
// Determine the final button title (tooltip)
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ export function DeleteActionButton<T = any>({
|
|||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('common.delete', 'Delete');
|
||||
const confirmButtonTitle = confirmTitle || t('formgen.delete.confirm', 'Confirm delete');
|
||||
const cancelButtonTitle = cancelTitle || t('formgen.delete.cancel', 'Cancel delete');
|
||||
const buttonTitle = title || t('Löschen');
|
||||
const confirmButtonTitle = confirmTitle || t('Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?');
|
||||
const cancelButtonTitle = cancelTitle || t('Abbrechen');
|
||||
|
||||
// Check if ANY deletion is in progress (not just this specific item)
|
||||
const isAnyDeletionInProgress = loadingState && loadingState.size > 0;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function DownloadActionButton<T = any>({
|
|||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('files.action.download', 'Download');
|
||||
const buttonTitle = title || t('Herunterladen');
|
||||
// Use hookData loading state if available
|
||||
const loadingState = hookData?.[loadingStateName];
|
||||
const actualIsLoading = loadingState?.has((row as any)[idField]) || loading || internalLoading;
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ export function EditActionButton<T = any>({
|
|||
setEditData(null);
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('files.action.edit', 'Edit');
|
||||
const buttonTitle = title || t('Bearbeiten');
|
||||
// Use hookData editing state if available, otherwise use passed isEditing
|
||||
const loadingState = hookData?.[loadingStateName];
|
||||
const actualIsEditing = loadingState?.has((row as any)[idField]) || isEditing;
|
||||
|
|
@ -260,7 +260,7 @@ export function EditActionButton<T = any>({
|
|||
{/* Edit Popup - Identical structure to CreateButton */}
|
||||
<Popup
|
||||
isOpen={isPopupOpen}
|
||||
title={t('files.edit.title', 'Edit Item')}
|
||||
title={t('Datei bearbeiten')}
|
||||
onClose={handleCancel}
|
||||
size="medium"
|
||||
closable={!internalLoading}
|
||||
|
|
@ -273,7 +273,7 @@ export function EditActionButton<T = any>({
|
|||
console.warn('EditActionButton: entityType or attributes must be provided for FormGeneratorForm');
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
{t('common.error', 'Error: Entity type or attributes must be provided')}
|
||||
{t('Fehler')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -286,8 +286,8 @@ export function EditActionButton<T = any>({
|
|||
mode="edit"
|
||||
onSubmit={handleSave}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText={t('common.save', 'Save')}
|
||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||
submitButtonText={t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function RemoveActionButton<T = any>({
|
|||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('files.action.remove', 'Remove from workflow');
|
||||
const buttonTitle = title || t('Entfernen');
|
||||
|
||||
// Use hookData removing state if available, otherwise use passed loading
|
||||
const loadingState = hookData?.[loadingStateName];
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function ViewActionButton<T = any>({
|
|||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('files.action.preview', 'Preview');
|
||||
const buttonTitle = title || t('Vorschau');
|
||||
// Use hookData viewing state if available, otherwise use passed isViewing
|
||||
const loadingState = hookData?.[loadingStateName];
|
||||
const actualIsViewing = loadingState?.has((row as any)[idField]) || isViewing;
|
||||
|
|
@ -109,7 +109,7 @@ export function ViewActionButton<T = any>({
|
|||
{!isFile && (
|
||||
<Popup
|
||||
isOpen={isPopupOpen}
|
||||
title={t('common.details', 'Details')}
|
||||
title={t('Details')}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
size="large"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function FormGeneratorControls({
|
|||
size="sm"
|
||||
icon={FaTrash}
|
||||
>
|
||||
{t('formgen.delete.single', 'Delete')}
|
||||
{t('Löschen')}
|
||||
</Button>
|
||||
)}
|
||||
{(selectedCount > 1 || (selectedCount === displayData.length && displayData.length > 0)) && onDeleteMultiple && (
|
||||
|
|
@ -132,8 +132,8 @@ export function FormGeneratorControls({
|
|||
icon={FaTrash}
|
||||
>
|
||||
{allItemsSelected
|
||||
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
|
||||
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||
? t('Alle {count} Elemente löschen', { count: selectedCount.toString() })
|
||||
: t('Löschen ({count})', { count: selectedCount.toString() })}
|
||||
</Button>
|
||||
)}
|
||||
{batchActions?.map((action, idx) => (
|
||||
|
|
@ -145,7 +145,7 @@ export function FormGeneratorControls({
|
|||
icon={action.icon}
|
||||
disabled={action.loading}
|
||||
>
|
||||
{action.loading ? t('common.loading', 'Loading...') : action.label}
|
||||
{action.loading ? t('Laden...') : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -165,30 +165,30 @@ export function FormGeneratorControls({
|
|||
className={`${styles.searchInput} ${searchFocused || searchTerm ? styles.focused : ''}`}
|
||||
/>
|
||||
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>
|
||||
{t('formgen.search.placeholder')}
|
||||
{t('Suchen...')}
|
||||
</label>
|
||||
</div>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className={styles.activeFiltersCount}>
|
||||
{activeFiltersCount} {t('formgen.filter.active', 'filter(s)')}
|
||||
{activeFiltersCount} {t('Filter')}
|
||||
</span>
|
||||
)}
|
||||
{onCsvExport && (
|
||||
<button
|
||||
onClick={onCsvExport}
|
||||
className={styles.csvExportButton}
|
||||
title={t('formgen.export.csv', 'Export all data as CSV')}
|
||||
title={t('Alle Daten als CSV exportieren')}
|
||||
disabled={csvExporting}
|
||||
>
|
||||
<span className={styles.csvExportIcon}><FaDownload /></span>
|
||||
{csvExporting ? t('formgen.export.exporting', 'Exporting...') : 'CSV'}
|
||||
{csvExporting ? t('Exportiere...') : 'CSV'}
|
||||
</button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className={styles.refreshButton}
|
||||
title={t('formgen.refresh.tooltip', 'Refresh data')}
|
||||
title={t('Daten aktualisieren')}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className={styles.refreshIcon}><IoIosRefresh /></span>
|
||||
|
|
@ -200,7 +200,7 @@ export function FormGeneratorControls({
|
|||
<div className={styles.paginationControls}>
|
||||
{showPageSizeSelector && onPageSizeChange && (
|
||||
<div className={styles.pageSizeSelector}>
|
||||
<label htmlFor="pageSize">{t('formgen.pagination.pageSize', 'Items per page:')}</label>
|
||||
<label htmlFor="pageSize">{t('Einträge pro Seite:')}</label>
|
||||
<select
|
||||
id="pageSize"
|
||||
value={currentPageSize}
|
||||
|
|
@ -218,7 +218,7 @@ export function FormGeneratorControls({
|
|||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.first')}
|
||||
title={t('Erste Seite')}
|
||||
>
|
||||
««
|
||||
</button>
|
||||
|
|
@ -226,7 +226,7 @@ export function FormGeneratorControls({
|
|||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.prev')}
|
||||
title={t('Vorherige Seite')}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
|
@ -254,7 +254,7 @@ export function FormGeneratorControls({
|
|||
onClick={() => onPageChange(i)}
|
||||
disabled={i === currentPage}
|
||||
className={`${styles.pageNumber} ${i === currentPage ? styles.pageNumberActive : ''}`}
|
||||
title={`${t('formgen.pagination.page', 'Page')} ${i}`}
|
||||
title={`${t('Seite')} ${i}`}
|
||||
>
|
||||
{i}
|
||||
</button>
|
||||
|
|
@ -276,7 +276,7 @@ export function FormGeneratorControls({
|
|||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.next')}
|
||||
title={t('Nächste Seite')}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
|
|
@ -284,13 +284,13 @@ export function FormGeneratorControls({
|
|||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.last')}
|
||||
title={t('Letzte Seite')}
|
||||
>
|
||||
»»
|
||||
</button>
|
||||
|
||||
<span className={styles.paginationInfo}>
|
||||
({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')})
|
||||
({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('Einträge')})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -467,12 +467,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
const isMultilingualField = isMultilingualType(attr.type as AttributeType);
|
||||
if (isMultilingualField && isTextMultilingual(value)) {
|
||||
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
|
||||
newErrors[attr.name] = t('formgen.form.required', `${attr.label} (English) is required`);
|
||||
newErrors[attr.name] = t('{fieldLabel} ist erforderlich', {
|
||||
fieldLabel: `${attr.label} (Englisch)`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0)) {
|
||||
newErrors[attr.name] = t('formgen.form.required', `${attr.label} is required`);
|
||||
newErrors[attr.name] = t('{fieldLabel} ist erforderlich', { fieldLabel: attr.label });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -482,7 +484,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
// Integer validation
|
||||
if (attr.type === 'integer') {
|
||||
if (!Number.isInteger(Number(value)) || isNaN(Number(value))) {
|
||||
newErrors[attr.name] = t('formgen.form.invalidInteger', `${attr.label} must be a valid integer`);
|
||||
newErrors[attr.name] = t('{fieldLabel} muss eine gültige Ganzzahl sein', {
|
||||
fieldLabel: attr.label,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -490,7 +494,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
// Number/Float validation
|
||||
if (isNumberType(attr.type)) {
|
||||
if (isNaN(Number(value))) {
|
||||
newErrors[attr.name] = t('formgen.form.invalidNumber', `${attr.label} must be a valid number`);
|
||||
newErrors[attr.name] = t('{fieldLabel} muss eine gültige Zahl sein', {
|
||||
fieldLabel: attr.label,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -498,7 +504,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
// Email validation
|
||||
if (attr.type === 'email') {
|
||||
if (!/\S+@\S+\.\S+/.test(String(value))) {
|
||||
newErrors[attr.name] = t('formgen.form.invalidEmail', 'Invalid email format');
|
||||
newErrors[attr.name] = t('Ungültiges E-Mail-Format');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -508,7 +514,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
try {
|
||||
new URL(String(value));
|
||||
} catch {
|
||||
newErrors[attr.name] = t('formgen.form.invalidUrl', 'Invalid URL format');
|
||||
newErrors[attr.name] = t('Ungültige URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -517,7 +523,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
if (isSelectType(attr.type)) {
|
||||
const options = normalizeOptions(attr);
|
||||
if (options.length > 0 && !options.some(opt => String(opt.value) === String(value))) {
|
||||
newErrors[attr.name] = t('formgen.form.invalidOption', 'Invalid option selected');
|
||||
newErrors[attr.name] = t('Ungültige Auswahl');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -535,7 +541,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
isValid = !isNaN(dateValue.getTime());
|
||||
}
|
||||
if (!isValid) {
|
||||
newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format');
|
||||
newErrors[attr.name] = t('Ungültiges Datumsformat');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -682,7 +688,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
return (
|
||||
<div className={styles.fieldGroup} key={attr.name}>
|
||||
<div className={styles.readonlyField}>
|
||||
{displayValues || t('common.na', 'N/A')}
|
||||
{displayValues || t('k. A.')}
|
||||
</div>
|
||||
<label className={styles.focusedLabel}>
|
||||
{attr.label}
|
||||
|
|
@ -735,7 +741,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
if (isReadonly) {
|
||||
let displayValue = value;
|
||||
if (isCheckboxType(attr.type)) {
|
||||
displayValue = value ? t('common.yes', 'Yes') : t('common.no', 'No');
|
||||
displayValue = value ? t('Ja') : t('Nein');
|
||||
} else if (isSelectType(attr.type)) {
|
||||
const options = normalizeOptions(attr);
|
||||
const selectedOption = options.find(opt => String(opt.value) === String(value));
|
||||
|
|
@ -746,7 +752,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
displayValue = selectedValues.map(v => {
|
||||
const option = options.find(opt => String(opt.value) === String(v));
|
||||
return option ? option.label : v;
|
||||
}).join(', ') || t('common.none', 'None');
|
||||
}).join(', ') || t('Keine');
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Convert objects/arrays to formatted JSON string for display
|
||||
displayValue = JSON.stringify(value, null, 2);
|
||||
|
|
@ -758,7 +764,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
return (
|
||||
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||
<div className={styles.readonlyField} style={isJsonValue ? { whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85em' } : undefined}>
|
||||
{displayValue || t('common.na', 'N/A')}
|
||||
{displayValue || t('k. A.')}
|
||||
</div>
|
||||
<label className={styles.focusedLabel}>
|
||||
{attr.label}
|
||||
|
|
@ -808,14 +814,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
{attr.label}
|
||||
{attr.required && <span className={styles.required}>*</span>}
|
||||
{currentValues.length > 0 && (
|
||||
<span className={styles.multiselectCount}> ({currentValues.length} {t('common.selected', 'selected')})</span>
|
||||
<span className={styles.multiselectCount}> ({currentValues.length} {t('ausgewählt')})</span>
|
||||
)}
|
||||
</label>
|
||||
<div className={`${styles.multiselectContainer} ${hasError ? styles.fieldError : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className={styles.multiselectLoading}>{t('common.loading', 'Loading options...')}</div>
|
||||
<div className={styles.multiselectLoading}>{t('Laden...')}</div>
|
||||
) : options.length === 0 ? (
|
||||
<div className={styles.multiselectEmpty}>{t('common.noOptions', 'No options available')}</div>
|
||||
<div className={styles.multiselectEmpty}>{t('Keine Optionen verfügbar')}</div>
|
||||
) : (
|
||||
<div className={styles.multiselectOptions}>
|
||||
{options.map(option => {
|
||||
|
|
@ -1022,7 +1028,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
<div className={`${styles.formGeneratorForm} ${className}`}>
|
||||
<div className={styles.loadingState}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
<p>{t('Laden...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1043,7 +1049,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{cancelButtonText || t('common.cancel', 'Cancel')}
|
||||
{cancelButtonText || t('Abbrechen')}
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'display' && (
|
||||
|
|
@ -1053,7 +1059,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t('common.saving', 'Saving...') : (submitButtonText || t('common.save', 'Save'))}
|
||||
{submitting ? t('Speichern...') : (submitButtonText || t('Speichern'))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -654,7 +654,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
{loading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
<p>{t('Laden...')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -671,7 +671,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.confirmButton}`}
|
||||
title={t('formgen.delete.confirm', 'Confirm delete')}
|
||||
title={t('Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?')}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<span className={actionButtonStyles.actionIcon}>
|
||||
|
|
@ -681,7 +681,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
<button
|
||||
onClick={handleCancelDelete}
|
||||
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.cancelButton}`}
|
||||
title={t('formgen.delete.cancel', 'Cancel delete')}
|
||||
title={t('Abbrechen')}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<span className={actionButtonStyles.actionIcon}>
|
||||
|
|
@ -694,8 +694,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onClick={handleDeleteMultipleClick}
|
||||
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.delete} ${styles.headerDeleteButton} ${isDeleting ? actionButtonStyles.loading : ''}`}
|
||||
title={allItemsSelected
|
||||
? t('formgen.delete.all', `Delete all ${selectedItems.size} items`)
|
||||
: t('formgen.delete.multiple', `Delete ${selectedItems.size} selected items`)}
|
||||
? t('Alle {count} Elemente löschen', { count: String(selectedItems.size) })
|
||||
: t('Löschen ({count})', { count: String(selectedItems.size) })}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<span className={actionButtonStyles.actionIcon}>
|
||||
|
|
@ -716,7 +716,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
return selectedItems.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
title={t('formgen.select.all', 'Select all items')}
|
||||
title={t('Alle Elemente auswählen')}
|
||||
className={styles.selectAllCheckbox}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -757,7 +757,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
{/* List Items */}
|
||||
{displayData.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||
{emptyMessage || t('Keine Daten verfügbar')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.itemsList}>
|
||||
|
|
@ -785,8 +785,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
disabled={isItemSelectable && !isItemSelectable(row)}
|
||||
title={
|
||||
isItemSelectable && !isItemSelectable(row)
|
||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||
: t('formgen.select.item', 'Select this item')
|
||||
? t('Dieses Element kann nicht ausgewählt werden')
|
||||
: t('Dieses Element auswählen')
|
||||
}
|
||||
className={styles.itemCheckbox}
|
||||
/>
|
||||
|
|
@ -966,7 +966,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
<div className={styles.pagination}>
|
||||
{showPageSizeSelector && (
|
||||
<div className={styles.pageSizeSelector}>
|
||||
<label htmlFor="pageSize">{t('formgen.pagination.pageSize', 'Items per page:')}</label>
|
||||
<label htmlFor="pageSize">{t('Einträge pro Seite:')}</label>
|
||||
<select
|
||||
id="pageSize"
|
||||
value={currentPageSize}
|
||||
|
|
@ -986,7 +986,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.first')}
|
||||
title={t('Erste Seite')}
|
||||
>
|
||||
««
|
||||
</button>
|
||||
|
|
@ -994,13 +994,13 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.prev')}
|
||||
title={t('Vorherige Seite')}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
<span className={styles.paginationInfo}>
|
||||
{t('formgen.pagination.info')
|
||||
{t('Seite {page} von {total} ({count} Einträge)')
|
||||
.replace('{page}', currentPage.toString())
|
||||
.replace('{total}', totalPages.toString())
|
||||
.replace('{count}', supportsBackendPagination && hookData?.pagination
|
||||
|
|
@ -1012,7 +1012,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.next')}
|
||||
title={t('Nächste Seite')}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
|
|
@ -1020,7 +1020,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={styles.paginationButton}
|
||||
title={t('formgen.pagination.last')}
|
||||
title={t('Letzte Seite')}
|
||||
>
|
||||
»»
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -406,4 +406,61 @@
|
|||
.chartWrapper {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.pieChartContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pieLegend {
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================ */
|
||||
/* PIE CHART LEGEND - right-aligned, label only, legible font */
|
||||
/* ============================================================================ */
|
||||
|
||||
.pieChartContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pieChartSvg {
|
||||
flex: 1 1 55%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pieLegend {
|
||||
flex: 0 0 auto;
|
||||
max-width: 45%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 12px;
|
||||
overflow-y: auto;
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
.pieLegendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pieLegendDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pieLegendLabel {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,6 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
|
|||
}
|
||||
|
||||
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
|
||||
const total = section.data.reduce((sum, d) => sum + d.value, 0);
|
||||
|
||||
const chartData = section.data.map((d, i) => ({
|
||||
name: d.key,
|
||||
|
|
@ -294,41 +293,40 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
|
|||
color: d.color || CHART_COLORS[i % CHART_COLORS.length]
|
||||
}));
|
||||
|
||||
const _renderLabel = ({ name, percent }: any) => {
|
||||
if (percent < 0.05) return null;
|
||||
return `${name} (${(percent * 100).toFixed(0)}%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartWrapperSmall}>
|
||||
<ResponsiveContainer width="100%" height={230} minWidth={0}>
|
||||
<PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={section.donut ? '45%' : 0}
|
||||
outerRadius="65%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={_renderLabel}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined, name: string | undefined) => [formatter(value ?? 0), name ?? '']}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) => {
|
||||
const item = chartData.find(d => d.name === value);
|
||||
return item ? `${value} (${((item.value / total) * 100).toFixed(1)}%)` : value;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className={styles.pieChartContainer}>
|
||||
<div className={styles.pieChartSvg}>
|
||||
<ResponsiveContainer width="100%" height={220} minWidth={0}>
|
||||
<PieChart margin={{ top: 10, right: 0, left: 0, bottom: 10 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={section.donut ? '45%' : 0}
|
||||
outerRadius="80%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={false}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined, name: string | undefined) => [formatter(value ?? 0), name ?? '']}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className={styles.pieLegend}>
|
||||
{chartData.map((entry, i) => (
|
||||
<div key={i} className={styles.pieLegendItem}>
|
||||
<span className={styles.pieLegendDot} style={{ background: entry.color }} />
|
||||
<span className={styles.pieLegendLabel}>{entry.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -146,8 +146,7 @@
|
|||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
|
@ -176,6 +175,8 @@
|
|||
.columnLabel {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sortIcon {
|
||||
|
|
@ -237,7 +238,7 @@
|
|||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
z-index: 1000;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -932,7 +932,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
try {
|
||||
let values: string[];
|
||||
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
||||
values = await hookData.fetchFilterValues(columnKey);
|
||||
const crossFilters: Record<string, any> = {};
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (k !== columnKey && v !== undefined && v !== '') crossFilters[k] = v;
|
||||
});
|
||||
values = await hookData.fetchFilterValues(columnKey, crossFilters);
|
||||
} else if (apiEndpoint && supportsBackendPagination) {
|
||||
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
|
||||
const params: Record<string, string> = { column: columnKey };
|
||||
|
|
@ -1481,7 +1485,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
e.stopPropagation(); // Prevent row click
|
||||
handleInlineToggle(row, column, boolValue);
|
||||
}}
|
||||
title={t('formgen.table.clickToToggle', 'Click to toggle')}
|
||||
title={t('Zum Ein-/Ausklappen klicken')}
|
||||
>
|
||||
{boolValue ? '✓' : '○'}
|
||||
</button>
|
||||
|
|
@ -1832,7 +1836,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
{loading && (
|
||||
<div className={styles.loadingOverlay}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
<p>{t('Laden...')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1851,7 +1855,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return selectedRows.size === selectableIndices.length && selectableIndices.length > 0;
|
||||
})()}
|
||||
onChange={handleSelectAll}
|
||||
title={t('formgen.select.all', 'Select all items')}
|
||||
title={t('Alle Elemente auswählen')}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
|
@ -1890,8 +1894,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
className={`${styles.filterIcon} ${filters[column.key] ? styles.filterActive : ''}`}
|
||||
onClick={(e) => toggleFilterDropdown(column.key, e)}
|
||||
title={filters[column.key]
|
||||
? t('formgen.filter.active_value', `Filter: ${filters[column.key]}`).replace('{value}', String(filters[column.key]))
|
||||
: t('formgen.filter.click', 'Click to filter')
|
||||
? t('Filter: {value}', { value: String(filters[column.key]) })
|
||||
: t('Zum Filtern klicken')
|
||||
}
|
||||
>
|
||||
<FaFilter size={10} />
|
||||
|
|
@ -1905,8 +1909,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
className={`${styles.sortIcon} ${sortInfo ? styles.sortActive : ''}`}
|
||||
onClick={() => handleSort(column.key)}
|
||||
title={sortInfo
|
||||
? t('formgen.sort.active', `Sort ${sortInfo.position}: ${sortInfo.direction === 'asc' ? 'ascending' : 'descending'}`)
|
||||
: t('formgen.sort.click', 'Click to sort')
|
||||
? t('Sortierung {position}: {direction}', {
|
||||
position: String(sortInfo.position),
|
||||
direction:
|
||||
sortInfo.direction === 'asc' ? 'aufsteigend' : 'absteigend',
|
||||
})
|
||||
: t('Zum Sortieren klicken')
|
||||
}
|
||||
>
|
||||
{sortInfo ? (
|
||||
|
|
@ -1935,12 +1943,12 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={styles.filterDropdownHeader}>
|
||||
<span>{t('formgen.filter.title', 'Filter')}: {column.label}</span>
|
||||
<span>{t('Filter')}: {column.label}</span>
|
||||
{filters[column.key] && (
|
||||
<button
|
||||
className={styles.filterClearBtn}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
title={t('formgen.filter.clear', 'Clear filter')}
|
||||
title={t('Filter löschen')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -1960,19 +1968,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
className={`${styles.filterOption} ${!currentVal ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
({t('Alle')})
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.filterOption} ${currentVal === 'true' ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => handleFilter(column.key, 'true')}
|
||||
>
|
||||
{t('formgen.filter.yes', 'Ja')}
|
||||
{t('Ja')}
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.filterOption} ${currentVal === 'false' ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => handleFilter(column.key, 'false')}
|
||||
>
|
||||
{t('formgen.filter.no', 'Nein')}
|
||||
{t('Nein')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -1986,10 +1994,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
({t('Alle')})
|
||||
</div>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
{t('formgen.filter.from', 'Von')}
|
||||
{t('Von')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
|
|
@ -2006,7 +2014,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
}}
|
||||
/>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
{t('formgen.filter.to', 'Bis')}
|
||||
{t('Bis')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
|
|
@ -2032,11 +2040,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
({t('Alle')})
|
||||
</div>
|
||||
{filterValuesLoading[column.key] ? (
|
||||
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
||||
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
||||
{t('Lade Filterwerte...')}
|
||||
</div>
|
||||
) : (
|
||||
<FilterValuesList
|
||||
|
|
@ -2160,8 +2168,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
isRowSelectable && !isRowSelectable(row)
|
||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||
: t('formgen.select.item', 'Select this item')
|
||||
? t('Dieses Element kann nicht ausgewählt werden')
|
||||
: t('Dieses Element auswählen')
|
||||
}
|
||||
style={{
|
||||
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||
|
|
@ -2275,8 +2283,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={
|
||||
isRowSelectable && !isRowSelectable(row)
|
||||
? t('formgen.select.disabled', 'This item cannot be selected')
|
||||
: t('formgen.select.item', 'Select this item')
|
||||
? t('Dieses Element kann nicht ausgewählt werden')
|
||||
: t('Dieses Element auswählen')
|
||||
}
|
||||
style={{
|
||||
opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1,
|
||||
|
|
@ -2370,7 +2378,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length}
|
||||
style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }}
|
||||
>
|
||||
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||
{emptyMessage || t('Keine Daten verfügbar')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,11 @@ import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigati
|
|||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './MandateNavigation.module.css';
|
||||
|
||||
type NavTranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS - Convert API blocks to TreeItems
|
||||
// =============================================================================
|
||||
|
|
@ -92,13 +95,14 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
|||
function featureInstanceToTreeNode(
|
||||
instance: FeatureInstance,
|
||||
featureUiComponent: string,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
|
||||
tr: NavTranslateFn,
|
||||
): TreeNodeItem {
|
||||
const children = instance.views.map(featureViewToTreeNode);
|
||||
const renameAction = instance.isAdmin && onRename ? (
|
||||
<button
|
||||
className={styles.renameButton}
|
||||
title="Umbenennen"
|
||||
title={tr('Umbenennen')}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
|
||||
>
|
||||
<FaPen size={10} />
|
||||
|
|
@ -127,7 +131,8 @@ function featureInstanceToTreeNode(
|
|||
*/
|
||||
function navigationMandateToTreeNode(
|
||||
mandate: NavigationMandate,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
|
||||
tr: NavTranslateFn,
|
||||
): TreeNodeItem | null {
|
||||
if (mandate.features.length === 0) {
|
||||
return null;
|
||||
|
|
@ -136,7 +141,7 @@ function navigationMandateToTreeNode(
|
|||
const instanceNodes: TreeNodeItem[] = [];
|
||||
for (const feature of mandate.features) {
|
||||
for (const instance of feature.instances) {
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename));
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename, tr));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,10 +162,11 @@ function navigationMandateToTreeNode(
|
|||
*/
|
||||
function dynamicBlockToTreeNodes(
|
||||
block: DynamicBlock,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
|
||||
tr: NavTranslateFn,
|
||||
): TreeNodeItem[] {
|
||||
return block.mandates
|
||||
.map((m) => navigationMandateToTreeNode(m, onRename))
|
||||
.map((m) => navigationMandateToTreeNode(m, onRename, tr))
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
}
|
||||
|
||||
|
|
@ -168,50 +174,57 @@ function dynamicBlockToTreeNodes(
|
|||
// LOADING STATE
|
||||
// =============================================================================
|
||||
|
||||
const LoadingState: React.FC = () => (
|
||||
const LoadingState: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className={styles.loadingState}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
<span>Navigation wird geladen...</span>
|
||||
<span>{t('Navigation wird geladen…')}</span>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// EMPTY STATE
|
||||
// =============================================================================
|
||||
|
||||
const EmptyState: React.FC = () => (
|
||||
const EmptyState: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<p>Keine Feature-Instanzen verfügbar.</p>
|
||||
<p>{t('Keine Feature-Instanzen verfügbar.')}</p>
|
||||
<p className={styles.emptyHint}>
|
||||
Kontaktiere einen Administrator, um Zugriff zu erhalten.
|
||||
{t('Kontaktiere einen Administrator, um Zugriff zu erhalten.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const MandateNavigation: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { blocks, loading, refresh } = useNavigation('de');
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const { showWarning } = useToast();
|
||||
|
||||
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
|
||||
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
|
||||
const newLabel = await prompt(t('Neuer Name:'), { title: t('Umbenennen'), defaultValue: currentLabel });
|
||||
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||
try {
|
||||
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
|
||||
refresh();
|
||||
} catch (err: any) {
|
||||
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
|
||||
showWarning(t('Fehler'), t('Umbenennung fehlgeschlagen: {detail}', { detail: String(err?.response?.data?.detail || err.message) }));
|
||||
}
|
||||
}, [refresh, prompt, showWarning]);
|
||||
}, [refresh, prompt, showWarning, t]);
|
||||
|
||||
const navigationItems: TreeItem[] = useMemo(() => {
|
||||
const items: TreeItem[] = [];
|
||||
|
||||
const meineSichtItems: NavigationItem[] = [];
|
||||
let systemBlock: { items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null;
|
||||
let adminItems: NavigationItem[] = [];
|
||||
let adminSubgroups: NavSubgroup[] = [];
|
||||
|
||||
|
|
@ -223,19 +236,44 @@ export const MandateNavigation: React.FC = () => {
|
|||
} else {
|
||||
adminItems = [...block.items];
|
||||
}
|
||||
} else if (block.id === 'system') {
|
||||
systemBlock = { items: block.items || [], subgroups: block.subgroups };
|
||||
} else if (block.items.length > 0) {
|
||||
meineSichtItems.push(...block.items);
|
||||
// Legacy: other static blocks get merged as flat items
|
||||
if (!systemBlock) systemBlock = { items: [], subgroups: [] };
|
||||
systemBlock.items.push(...block.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meineSichtItems.length > 0) {
|
||||
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
||||
if (systemBlock) {
|
||||
const children: TreeNodeItem[] = [];
|
||||
for (const item of systemBlock.items) {
|
||||
children.push(navigationItemToTreeNode(item));
|
||||
}
|
||||
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
|
||||
for (const sg of systemBlock.subgroups) {
|
||||
children.push({
|
||||
id: sg.id,
|
||||
label: sg.title,
|
||||
children: sg.items.map(navigationItemToTreeNode),
|
||||
defaultExpanded: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (children.length > 0) {
|
||||
items.push({
|
||||
id: 'meine-sicht',
|
||||
label: t('Meine Sicht'),
|
||||
children,
|
||||
defaultExpanded: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'dynamic') {
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename, t);
|
||||
if (mandateNodes.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
items.push(...mandateNodes);
|
||||
|
|
@ -253,17 +291,17 @@ export const MandateNavigation: React.FC = () => {
|
|||
}));
|
||||
items.push({
|
||||
id: 'administration',
|
||||
label: 'Administration',
|
||||
label: t('Administration'),
|
||||
children: subgroupNodes,
|
||||
defaultExpanded: false,
|
||||
});
|
||||
} else if (adminItems.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
|
||||
items.push(_staticItemsToTreeNode('administration', t('Administration'), adminItems, false));
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [blocks, _handleRename]);
|
||||
}, [blocks, _handleRename, t]);
|
||||
|
||||
// Check if user has any navigation (static or dynamic)
|
||||
const hasNavigation = blocks.length > 0;
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
{isCreating && (
|
||||
<div className="spinnerIcon" style={{ marginRight: '8px' }} />
|
||||
)}
|
||||
{children || (isCreating ? t('common.creating', 'Creating...') : t('common.create', 'Create'))}
|
||||
{children || (isCreating ? t('Erstellen...') : t('Erstellen'))}
|
||||
</Button>
|
||||
|
||||
{/* Create Popup */}
|
||||
|
|
@ -193,8 +193,8 @@ const CreateButton: React.FC<CreateButtonProps> = ({
|
|||
mode="create"
|
||||
onSubmit={handleSave}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText={t('common.create', 'Create')}
|
||||
cancelButtonText={t('common.cancel', 'Cancel')}
|
||||
submitButtonText={t('Erstellen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ export function CopyableTruncatedValue({
|
|||
};
|
||||
|
||||
const tooltipText = copied
|
||||
? t('common.copied', 'Copied!')
|
||||
: `${t('common.copy', 'copy')}: ${value}`;
|
||||
? t('Kopiert')
|
||||
: `${t('Kopieren')}: ${value}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function DragDropOverlay({
|
|||
console.log('Has onDrop:', !!config.onDrop);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t('dragdrop.overlay.error', 'Error processing files'), error);
|
||||
console.error(t('Fehler beim Verarbeiten der Dateien'), error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ export function DragDropOverlay({
|
|||
try {
|
||||
await config.onDrop(files);
|
||||
} catch (error) {
|
||||
console.error(t('dragdrop.overlay.error', 'Error processing files'), error);
|
||||
console.error(t('Fehler beim Verarbeiten der Dateien'), error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
// Reset input
|
||||
|
|
@ -166,11 +166,11 @@ export function DragDropOverlay({
|
|||
<IoFolderOpen />
|
||||
</div>
|
||||
<div className={styles.dragText}>
|
||||
{config.overlayText || t('dragdrop.overlay.default_text', 'Drop files here')}
|
||||
{config.overlayText || t('Dateien hier ablegen')}
|
||||
</div>
|
||||
{(config.overlaySubtext || !config.overlayText) && (
|
||||
<div className={styles.dragSubtext}>
|
||||
{config.overlaySubtext || t('dragdrop.overlay.default_subtext', 'You can also click the upload button')}
|
||||
{config.overlaySubtext || t('Sie können auch auf den Upload-Button klicken')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -183,7 +183,7 @@ export function DragDropOverlay({
|
|||
<div className={styles.processingContent}>
|
||||
<div className={styles.spinner}></div>
|
||||
<div className={styles.processingText}>
|
||||
{t('dragdrop.overlay.processing', 'Processing files...')}
|
||||
{t('Dateien werden verarbeitet...')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ import {
|
|||
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||
FaListAlt, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||
FaFileContract,
|
||||
FaFileContract, FaRobot, FaGlobe,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -45,7 +45,12 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.system.files': <FaRegFileAlt />,
|
||||
'page.system.connections': <FaLink />,
|
||||
|
||||
// Billing pages
|
||||
// System pages - Usage
|
||||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||
'page.system.statistics': <FaChartBar />,
|
||||
'page.system.automations': <FaRobot />,
|
||||
|
||||
// Billing pages (legacy compat)
|
||||
'page.billing.dashboard': <FaWallet />,
|
||||
'page.billing.transactions': <FaListAlt />,
|
||||
|
||||
|
|
@ -73,6 +78,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.admin.automationLogs': <FaClipboardList />,
|
||||
'page.admin.automation-logs': <FaClipboardList />,
|
||||
'page.admin.logs': <FaFileAlt />,
|
||||
'page.admin.languages': <FaGlobe />,
|
||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||
'page.admin.mandateWizard': <FaHatWizard />,
|
||||
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||
|
|
|
|||
|
|
@ -515,9 +515,9 @@ export function useFileOperations() {
|
|||
// Check if the response indicates a duplicate file
|
||||
if (fileData && fileData.isDuplicate && fileData.message) {
|
||||
const fileName = fileData.originalFileName || file.name;
|
||||
const messageTemplate = t('warning.duplicate_file.message');
|
||||
const messageTemplate = t('Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.');
|
||||
const message = messageTemplate.replace('{fileName}', fileName);
|
||||
showWarning(t('warning.duplicate_file.title'), message);
|
||||
showWarning(t('Datei bereits vorhanden'), message);
|
||||
}
|
||||
|
||||
// Dispatch event to notify other components about the new file
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export interface PaginationParams {
|
|||
/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
|
||||
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
|
||||
if (!instanceId || !featureCode) return undefined;
|
||||
if (featureCode === 'automation') return `/api/automations/${instanceId}`;
|
||||
if (featureCode === 'graphicalEditor') return `/api/workflows/${instanceId}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,928 +0,0 @@
|
|||
export default {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Zentrale',
|
||||
'nav.files': 'Dateien',
|
||||
'nav.team': 'Team-Bereich',
|
||||
'nav.connections': 'Verbindungen',
|
||||
'nav.workflows': 'Workflows',
|
||||
'nav.settings': 'Einstellungen',
|
||||
'nav.testSharepoint': 'SharePoint Test',
|
||||
'nav.speech': 'Sprache',
|
||||
'nav.transcript_management': 'Transkriptverwaltung',
|
||||
|
||||
// Settings page
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.appearance': 'Darstellung',
|
||||
'settings.language': 'Sprache',
|
||||
'settings.about': 'Über',
|
||||
'settings.version': 'Version',
|
||||
'settings.theme': 'Theme',
|
||||
'settings.theme.description': 'Wechseln Sie zwischen hellem und dunklem Modus',
|
||||
'settings.language.description': 'Wählen Sie Ihre bevorzugte Sprache',
|
||||
'settings.theme.light': 'Hell',
|
||||
'settings.theme.dark': 'Dunkel',
|
||||
'settings.theme.toggle.light': 'Zu hellem Modus wechseln',
|
||||
'settings.theme.toggle.dark': 'Zu dunklem Modus wechseln',
|
||||
'settings.userinfo': 'Benutzerinformationen',
|
||||
'settings.userinfo.description': 'Verwalten Sie Ihre Kontoinformationen',
|
||||
'settings.userinfo.username': 'Benutzername',
|
||||
'settings.userinfo.fullname': 'Vollständiger Name',
|
||||
'settings.userinfo.email': 'E-Mail-Adresse',
|
||||
'settings.userinfo.phone_name': 'Rufname am Telefon',
|
||||
'settings.userinfo.phone_name.description': 'Wie möchten Sie am Telefon genannt werden?',
|
||||
'settings.userinfo.language': 'Sprache',
|
||||
'settings.userinfo.privilege': 'Berechtigungsstufe',
|
||||
'settings.userinfo.enabled': 'Kontostatus',
|
||||
'settings.userinfo.auth_authority': 'Authentifizierungsanbieter',
|
||||
'settings.userinfo.enabled.true': 'Aktiv',
|
||||
'settings.userinfo.enabled.false': 'Inaktiv',
|
||||
'settings.userinfo.loading': 'Benutzerinformationen werden geladen...',
|
||||
'settings.userinfo.error': 'Fehler beim Laden der Benutzerinformationen',
|
||||
'settings.userinfo.save': 'Änderungen speichern',
|
||||
'settings.userinfo.saving': 'Speichern...',
|
||||
'settings.userinfo.success': 'Benutzerinformationen erfolgreich aktualisiert',
|
||||
'settings.userinfo.update_error': 'Fehler beim Aktualisieren der Benutzerinformationen',
|
||||
'settings.userinfo.managed_by': 'Verwaltet von {provider}',
|
||||
'settings.userinfo.managed_note': 'Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden',
|
||||
|
||||
// Languages
|
||||
'language.german': 'Deutsch',
|
||||
'language.english': 'English',
|
||||
'language.french': 'Français',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Laden...',
|
||||
'common.error': 'Fehler',
|
||||
'common.success': 'Erfolgreich',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.save': 'Speichern',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
'common.close': 'Schließen',
|
||||
'common.retry': 'Wiederholen',
|
||||
'common.create': 'Erstellen',
|
||||
'common.creating': 'Erstellen...',
|
||||
|
||||
// Auth
|
||||
'auth.login': 'Anmelden',
|
||||
'auth.register': 'Registrieren',
|
||||
'auth.logout': 'Abmelden',
|
||||
'auth.email': 'E-Mail',
|
||||
'auth.password': 'Passwort',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.prompt.template': 'Prompt Vorlage',
|
||||
'dashboard.prompt.settings': 'Einstellungen',
|
||||
'dashboard.chat.area': 'Chatbereich',
|
||||
'dashboard.chat.history': 'Workflow-Verlauf',
|
||||
'dashboard.log.title': 'Log',
|
||||
'dashboard.log.workflow': 'Workflow',
|
||||
'dashboard.log.no_workflow': 'Kein Workflow ausgewählt',
|
||||
'dashboard.log.loading': 'Logs werden geladen...',
|
||||
'dashboard.log.error': 'Fehler beim Laden der Logs',
|
||||
'dashboard.log.no_logs': 'Keine Logs für diesen Workflow verfügbar',
|
||||
'dashboard.log.waiting': 'Workflow läuft... Warte auf Logs...',
|
||||
'dashboard.log.fetch_failed': 'Logs konnten nicht geladen werden',
|
||||
'dashboard.log.level.info': 'INFO',
|
||||
'dashboard.workflow_dropdown.loading': 'Laden...',
|
||||
'dashboard.workflow_dropdown.error': 'Fehler',
|
||||
'dashboard.workflow_dropdown.select_workflow': 'Workflow auswählen',
|
||||
'dashboard.workflow_dropdown.available_workflows': 'Verfügbare Workflows',
|
||||
'dashboard.workflow_dropdown.no_workflows': 'Keine Workflows verfügbar',
|
||||
|
||||
// Workflow Stats
|
||||
'dashboard.stats.workflow': 'Workflow',
|
||||
'dashboard.stats.status': 'Status',
|
||||
'dashboard.stats.rounds': 'Runden',
|
||||
'dashboard.stats.messages': 'Nachrichten',
|
||||
'dashboard.stats.files': 'Dateien',
|
||||
'dashboard.stats.tokens': 'Token',
|
||||
'dashboard.stats.data_sent': 'Daten gesendet',
|
||||
'dashboard.stats.data_received': 'Daten empfangen',
|
||||
'dashboard.stats.success_rate': 'Erfolgsrate',
|
||||
'dashboard.stats.errors': 'Fehler',
|
||||
'dashboard.stats.started': 'Gestartet',
|
||||
|
||||
// Prompt Set
|
||||
'promptset.loading': 'Prompts werden geladen...',
|
||||
'promptset.error.loading': 'Fehler beim Laden der Prompts',
|
||||
'promptset.retry': 'Erneut versuchen',
|
||||
'promptset.new_prompt': 'Neuer Prompt',
|
||||
'promptset.prompt_count': 'Prompt',
|
||||
'promptset.prompt_count_plural': 'Prompts',
|
||||
'promptset.no_prompts': 'Keine Prompts verfügbar',
|
||||
'promptset.created': 'Erstellt',
|
||||
'promptset.run_tooltip': 'Prompt ausführen',
|
||||
'promptset.share_tooltip': 'Prompt teilen',
|
||||
'promptset.delete_tooltip': 'Prompt löschen',
|
||||
'promptset.confirm_delete': 'Klicken Sie erneut zum Bestätigen',
|
||||
'promptset.deleting': 'Löschen...',
|
||||
'promptset.confirm_click': 'Zum Bestätigen klicken',
|
||||
'promptset.delete_error': 'Fehler beim Löschen',
|
||||
'promptset.deleting_message': 'Prompt wird gelöscht...',
|
||||
|
||||
// Connections
|
||||
'connections.title': 'Verbindungen',
|
||||
'connections.subtitle': 'Verwalten Sie Ihre Service-Verbindungen',
|
||||
'connections.connect_google': 'Google verbinden',
|
||||
'connections.connect_microsoft': 'Microsoft verbinden',
|
||||
'connections.add_google_button': 'Google-Verbindung hinzufügen',
|
||||
'connections.add_microsoft_button': 'Microsoft-Verbindung hinzufügen',
|
||||
'connections.create_google_title': 'Google-Verbindung erstellen',
|
||||
'connections.create_microsoft_title': 'Microsoft-Verbindung erstellen',
|
||||
'connections.edit_connection_title': '{authority} Verbindung bearbeiten',
|
||||
'connections.update_connection': 'Verbindung aktualisieren',
|
||||
'connections.service_connections': 'Service-Verbindungen',
|
||||
'connections.error': 'Fehler',
|
||||
'connections.connection_error': 'Verbindungsfehler',
|
||||
'connections.disconnect_error': 'Trennungsfehler',
|
||||
'connections.unknown': 'Unbekannt',
|
||||
'connections.not_available': 'Nicht verfügbar',
|
||||
'connections.invalid_date': 'Ungültiges Datum',
|
||||
'connections.confirm_delete': 'Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?',
|
||||
'connections.confirm_delete_multiple': 'Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?',
|
||||
|
||||
// Connection Fields
|
||||
'connections.field.service': 'Service',
|
||||
'connections.field.status': 'Status',
|
||||
'connections.field.external_username': 'Externer Benutzername',
|
||||
'connections.field.external_email': 'Externe E-Mail',
|
||||
'connections.field.connected_at': 'Verbunden am',
|
||||
'connections.field.last_checked': 'Zuletzt geprüft',
|
||||
'connections.field.expires_at': 'Läuft ab am',
|
||||
|
||||
// Connection Columns
|
||||
'connections.column.username': 'Benutzername',
|
||||
'connections.column.email': 'E-Mail',
|
||||
'connections.column.authority': 'Service',
|
||||
'connections.column.status': 'Status',
|
||||
'connections.column.connectedat': 'Verbunden am',
|
||||
'connections.column.lastchecked': 'Zuletzt geprüft',
|
||||
'connections.column.expiresat': 'Läuft ab am',
|
||||
|
||||
// Connection Services
|
||||
'connections.service.google': 'Google',
|
||||
'connections.service.microsoft': 'Microsoft',
|
||||
'connections.service.local': 'Lokal',
|
||||
|
||||
// Connection Placeholders
|
||||
'connections.placeholder.external_username': 'Externen Benutzernamen eingeben',
|
||||
'connections.placeholder.external_email': 'Externe E-Mail-Adresse eingeben',
|
||||
|
||||
// Connection Actions
|
||||
'connections.action.edit': 'Bearbeiten',
|
||||
'connections.action.update': 'Aktualisieren',
|
||||
'connections.action.delete': 'Löschen',
|
||||
'connections.action.connect': 'Verbinden',
|
||||
'connections.action.refresh': 'Aktualisieren',
|
||||
|
||||
// Prompt Modal
|
||||
'modal.create_prompt': 'Neuen Prompt erstellen',
|
||||
'modal.name_required': 'Name ist erforderlich',
|
||||
'modal.content_required': 'Inhalt ist erforderlich',
|
||||
'modal.create_error': 'Fehler beim Erstellen des Prompts',
|
||||
'modal.name_label': 'Name',
|
||||
'modal.content_label': 'Inhalt',
|
||||
'modal.name_placeholder': 'Geben Sie einen Namen für den Prompt ein',
|
||||
'modal.content_placeholder': 'Geben Sie den Inhalt des Prompts ein',
|
||||
'modal.cancel': 'Abbrechen',
|
||||
'modal.creating': 'Erstellen...',
|
||||
'modal.create': 'Prompt erstellen',
|
||||
|
||||
// Share Modal
|
||||
'share_modal.title': 'Prompt teilen',
|
||||
'share_modal.select_users': 'Benutzer auswählen',
|
||||
'share_modal.select_all': 'Alle auswählen',
|
||||
'share_modal.deselect_all': 'Alle abwählen',
|
||||
'share_modal.loading_users': 'Benutzer werden geladen...',
|
||||
'share_modal.error_loading_users': 'Fehler beim Laden der Benutzer',
|
||||
'share_modal.no_users_available': 'Keine Benutzer verfügbar',
|
||||
'share_modal.no_users_selected': 'Bitte wählen Sie mindestens einen Benutzer aus',
|
||||
'share_modal.one_user_selected': '1 Benutzer ausgewählt',
|
||||
'share_modal.multiple_users_selected': '{count} Benutzer ausgewählt',
|
||||
'share_modal.custom_title': 'Benutzerdefinierter Titel (optional)',
|
||||
'share_modal.title_placeholder': 'Geben Sie einen benutzerdefinierten Titel ein',
|
||||
'share_modal.message': 'Nachricht (optional)',
|
||||
'share_modal.message_placeholder': 'Fügen Sie eine Nachricht für die Empfänger hinzu',
|
||||
'share_modal.share': 'Teilen',
|
||||
'share_modal.sharing': 'Wird geteilt...',
|
||||
'share_modal.share_error': 'Fehler beim Teilen des Prompts',
|
||||
|
||||
// Prompt Settings
|
||||
'prompt_settings.title': 'Prompt Einstellungen',
|
||||
'prompt_settings.content_placeholder': 'Einstellungen werden in zukünftigen Updates hinzugefügt.',
|
||||
|
||||
// Chat Area
|
||||
'chat.continue_conversation': 'Gespräch fortsetzen...',
|
||||
'chat.enter_message': 'Nachricht eingeben...',
|
||||
'chat.remove_file': 'Datei entfernen',
|
||||
'chat.attach_file': 'Datei anhängen',
|
||||
'chat.you': 'You',
|
||||
'chat.click_to_open': 'Klicken Sie, um zu öffnen',
|
||||
'chat.preview_document': 'Dokument vorschauen',
|
||||
'chat.download_document': 'Dokument herunterladen',
|
||||
'chat.workflow_failed': 'Workflow fehlgeschlagen.',
|
||||
'chat.retry_workflow': 'Nochmal versuchen',
|
||||
'chat.sending_followup': 'Folgenachricht wird gesendet...',
|
||||
'chat.sending_message': 'Nachricht wird gesendet...',
|
||||
'chat.error_prefix': 'Fehler:',
|
||||
'chat.error_loading_messages': 'Fehler beim Laden der Nachrichten:',
|
||||
'chat.loading_workflow_messages': 'Workflow-Nachrichten werden geladen...',
|
||||
'chat.start_conversation': 'Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …',
|
||||
|
||||
// Chat Input Area
|
||||
'chat.input.continue_workflow': 'Gespräch fortsetzen...',
|
||||
'chat.input.enter_message': 'Oder geben Sie Ihre Nachricht ein...',
|
||||
'chat.input.continuing_workflow': 'Workflow wird fortgesetzt',
|
||||
'chat.input.workflow': 'Workflow',
|
||||
'chat.input.files_attached': 'Datei',
|
||||
'chat.input.files_attached_plural': 'Dateien',
|
||||
'chat.input.files_attached_label': 'angehängt',
|
||||
'chat.input.error_prefix': 'Fehler:',
|
||||
'chat.input.attach_files': 'Dateien anhängen',
|
||||
'chat.input.sending': 'Wird gesendet...',
|
||||
'chat.input.processing': 'Wird verarbeitet...',
|
||||
'chat.input.continue': 'Fortsetzen',
|
||||
'chat.input.send': 'Senden',
|
||||
'chat.input.stop': 'Stoppen',
|
||||
'chat.input.stopping': 'Wird gestoppt...',
|
||||
'chat.input.drop_files_here': 'Dateien hier ablegen zum Anhängen',
|
||||
'chat.input.drop_disabled': 'Datei-Ablage während Workflow deaktiviert',
|
||||
'chat.input.new_chat': 'Chat leeren...',
|
||||
'chat.input.using_prompt': 'Verwende Vorlage:',
|
||||
'chat.input.select_prompt': 'Prompt auswählen...',
|
||||
'chat.input.loading_prompts': 'Prompts werden geladen...',
|
||||
'chat.input.clear_prompt': 'Prompt löschen',
|
||||
|
||||
// File Preview
|
||||
'file_preview.loading': 'Vorschau wird geladen...',
|
||||
'file_preview.error': 'Fehler',
|
||||
'file_preview.no_preview': 'Keine Vorschau verfügbar',
|
||||
'file_preview.close_preview': 'Vorschau schließen',
|
||||
'file_preview.python': 'Python',
|
||||
|
||||
// Chat History
|
||||
'chat_history.loading': 'Workflows werden geladen...',
|
||||
'chat_history.error_loading': 'Fehler beim Laden der Workflows:',
|
||||
'chat_history.try_again': 'Nochmal versuchen',
|
||||
'chat_history.title': 'Workflow-Verlauf',
|
||||
'chat_history.workflow_count': 'Workflow',
|
||||
'chat_history.workflow_count_plural': 'Workflows',
|
||||
'chat_history.empty_state': 'Keine Workflows verfügbar',
|
||||
'chat_history.confirm_delete': 'Sind Sie sicher, dass Sie Workflow "{id}..." löschen möchten?',
|
||||
'chat_history.no_message_content': 'Kein Nachrichteninhalt verfügbar',
|
||||
'chat_history.unknown_date': 'Unbekanntes Datum',
|
||||
'chat_history.invalid_date': 'Ungültiges Datum',
|
||||
'chat_history.started': 'Gestartet:',
|
||||
'chat_history.last_activity': 'Letzte Aktivität:',
|
||||
'chat_history.round': 'Runde',
|
||||
'chat_history.resume_tooltip': 'Workflow fortsetzen',
|
||||
'chat_history.delete_tooltip': 'Workflow löschen',
|
||||
'chat_history.deleting': 'Workflow wird gelöscht...',
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'Noch keinen Workflow ausgewählt',
|
||||
'chat.messages.no_workflow_selected_description': 'Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow',
|
||||
'chat.messages.loading_progress': 'Lade Fortschritt...',
|
||||
'chat.messages.tasks': 'Aufgaben',
|
||||
'chat.messages.workflow_progress': 'Workflow Fortschritt',
|
||||
'chat.messages.analyzing_workflow': 'Analysiere Workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Nach unten scrollen',
|
||||
// Workflow Status
|
||||
'status.error': 'FEHLER',
|
||||
'status.failed': 'FEHLGESCHLAGEN',
|
||||
'status.stopped': 'GESTOPPT',
|
||||
'status.cancelled': 'ABGEBROCHEN',
|
||||
'status.running': 'LÄUFT',
|
||||
'status.processing': 'VERARBEITUNG',
|
||||
'status.completed': 'ABGESCHLOSSEN',
|
||||
'status.pending': 'WARTEND',
|
||||
|
||||
// Files
|
||||
'files.unknown_size': 'Unbekannte Größe',
|
||||
'files.unknown_date': 'Unbekanntes Datum',
|
||||
'files.source.uploaded': 'Hochgeladen',
|
||||
'files.source.ai_created': 'KI-erstellt',
|
||||
'files.source.shared': 'Geteilt',
|
||||
'files.source.unknown': 'Unbekannt',
|
||||
'files.preview_tooltip': 'Datei vorschauen',
|
||||
'files.download_tooltip': 'Datei herunterladen',
|
||||
'files.delete_tooltip': 'Datei löschen',
|
||||
'files.delete_confirm_tooltip': 'Klicken Sie erneut zum Bestätigen der Löschung',
|
||||
'files.downloading': 'Laden...',
|
||||
'files.deleting': 'Löschen...',
|
||||
'files.delete_confirm': 'Zum Bestätigen klicken...',
|
||||
'files.no_files': 'Keine Dateien gefunden.',
|
||||
'files.no_shared_files': 'Keine mit Ihnen geteilten Dateien gefunden.',
|
||||
'files.no_ai_files': 'Keine von der KI erstellten Dateien gefunden.',
|
||||
'files.no_uploaded_files': 'Keine hochgeladenen Dateien gefunden.',
|
||||
'files.header.name': 'Name',
|
||||
'files.header.type': 'Typ',
|
||||
'files.header.size': 'Größe',
|
||||
'files.header.date': 'Datum',
|
||||
'files.selector.title': 'Dateien auswählen',
|
||||
'files.selector.tab.all': 'Alle Dateien',
|
||||
'files.selector.tab.uploads': 'Hochgeladen',
|
||||
'files.selector.tab.created': 'KI-erstellt',
|
||||
'files.selector.tab.shared': 'Geteilt',
|
||||
'files.selector.select_all': 'Alle auswählen',
|
||||
'files.selector.deselect_all': 'Alle abwählen',
|
||||
'files.selector.file_selected': 'Datei',
|
||||
'files.selector.files_selected': 'Dateien',
|
||||
'files.selector.selected_suffix': 'ausgewählt',
|
||||
'files.selector.upload_new': 'Neue Datei hochladen',
|
||||
'files.selector.loading': 'Dateien werden geladen...',
|
||||
'files.selector.error_loading': 'Fehler beim Laden der Dateien:',
|
||||
'files.upload.title': 'Datei hochladen',
|
||||
'files.upload.drop_here': 'Datei hier ablegen...',
|
||||
'files.upload.uploading': 'Lädt hoch...',
|
||||
'files.upload.drag_files': 'Dateien hierher ziehen',
|
||||
'files.upload.or': 'oder',
|
||||
'files.upload.browse': 'Durchsuchen',
|
||||
'files.upload.selected_file': 'Ausgewählte Datei:',
|
||||
'files.upload.upload_button': 'Hochladen',
|
||||
'files.upload.uploading_button': 'Wird hochgeladen...',
|
||||
'files.upload.success': 'Datei erfolgreich hochgeladen!',
|
||||
'files.upload.error': 'Beim Hochladen ist ein Fehler aufgetreten.',
|
||||
'files.upload.unexpected_error': 'Beim Hochladen ist ein unerwarteter Fehler aufgetreten.',
|
||||
|
||||
// Files Page Upload Actions
|
||||
'files.drop_zone': 'Dateien hier ablegen',
|
||||
'files.upload_button': 'Dateien hochladen',
|
||||
'files.uploading_button': 'Wird hochgeladen...',
|
||||
'files.upload_aria_label': 'Dateien hochladen',
|
||||
|
||||
// Files Page
|
||||
'files.title': 'Dateien',
|
||||
'files.table.title': 'Dateien',
|
||||
'files.error.loading': 'Fehler beim Laden der Dateien:',
|
||||
'files.button.retry': 'Wiederholen',
|
||||
'files.page.tab.all': 'Alle Dateien',
|
||||
'files.page.tab.uploads': 'Meine Uploads',
|
||||
'files.page.tab.created': 'Erstellte Dateien',
|
||||
'files.page.tab.shared': 'Geteilte Dateien',
|
||||
'files.page.add_file': 'Datei hinzufügen',
|
||||
'files.page.loading': 'Dateien werden geladen...',
|
||||
'files.page.error': 'Fehler:',
|
||||
|
||||
// File Table Columns
|
||||
'files.column.name': 'Name',
|
||||
'files.column.filename': 'Dateiname',
|
||||
'files.column.type': 'Typ',
|
||||
'files.column.mimetype': 'MIME-Typ',
|
||||
'files.column.size': 'Größe',
|
||||
'files.column.filesize': 'Dateigröße',
|
||||
'files.column.created': 'Erstellt',
|
||||
'files.column.creationdate': 'Erstellungsdatum',
|
||||
'files.column.source': 'Quelle',
|
||||
|
||||
// File Types
|
||||
'files.type.image': 'Bild',
|
||||
'files.type.pdf': 'PDF',
|
||||
'files.type.document': 'Dokument',
|
||||
'files.type.spreadsheet': 'Tabelle',
|
||||
'files.type.text': 'Text',
|
||||
'files.type.video': 'Video',
|
||||
'files.type.audio': 'Audio',
|
||||
'files.type.file': 'Datei',
|
||||
|
||||
// File Actions
|
||||
'files.action.preview': 'Vorschau',
|
||||
'files.action.download': 'Herunterladen',
|
||||
'files.action.delete': 'Löschen',
|
||||
'files.delete.confirm': 'Sind Sie sicher, dass Sie die Datei "{name}" löschen möchten?',
|
||||
|
||||
// File Preview
|
||||
'files.preview.title': 'Dateivorschau',
|
||||
'files.preview.loading': 'Vorschau wird geladen...',
|
||||
'files.preview.unsupported': 'Vorschau für diesen Dateityp nicht verfügbar',
|
||||
'files.preview.error': 'Fehler beim Laden der Vorschau',
|
||||
'files.preview.textInPdfFile': 'Textvorschau',
|
||||
'files.preview.pdfFileCorrupted': 'Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.',
|
||||
|
||||
// Workflows Page
|
||||
'workflows.title': 'Workflows',
|
||||
'workflows.table.title': 'Workflows',
|
||||
'workflows.error.loading': 'Fehler beim Laden der Workflows:',
|
||||
'workflows.button.retry': 'Wiederholen',
|
||||
'workflows.table.empty': 'Keine Workflows gefunden',
|
||||
|
||||
// Workflow Table Columns
|
||||
'workflows.column.id': 'ID',
|
||||
'workflows.column.name': 'Name',
|
||||
'workflows.column.status': 'Status',
|
||||
'workflows.column.round': 'Runde',
|
||||
'workflows.column.started': 'Gestartet',
|
||||
'workflows.column.lastActivity': 'Letzte Aktivität',
|
||||
'workflows.column.messages': 'Nachrichten',
|
||||
|
||||
// Workflow Status
|
||||
'workflows.status.running': 'Läuft',
|
||||
'workflows.status.completed': 'Abgeschlossen',
|
||||
'workflows.status.failed': 'Fehlgeschlagen',
|
||||
'workflows.status.stopped': 'Gestoppt',
|
||||
'workflows.status.pending': 'Wartend',
|
||||
|
||||
// Workflow Actions
|
||||
'workflows.action.stop': 'Stoppen',
|
||||
'workflows.action.delete': 'Löschen',
|
||||
'workflows.action.stop.tooltip': 'Workflow stoppen',
|
||||
'workflows.action.delete.tooltip': 'Workflow löschen',
|
||||
|
||||
// Workflow Messages
|
||||
'workflows.unnamed': 'Unbenannter Workflow',
|
||||
'workflows.delete.confirm': 'Sind Sie sicher, dass Sie den Workflow "{name}" löschen möchten?',
|
||||
'workflows.loading': 'Workflows werden geladen...',
|
||||
|
||||
// FormGenerator
|
||||
'formgen.search.placeholder': 'Suchen...',
|
||||
'formgen.refresh.tooltip': 'Daten aktualisieren',
|
||||
'formgen.filter.yes': 'Ja',
|
||||
'formgen.filter.no': 'Nein',
|
||||
'formgen.filter.clear': 'Filter löschen',
|
||||
'formgen.filter.placeholder': '{column} filtern',
|
||||
'formgen.actions.column': 'Aktionen',
|
||||
'formgen.pagination.info': 'Seite {page} von {total} ({count} Einträge)',
|
||||
'formgen.pagination.pageSize': 'Einträge pro Seite:',
|
||||
'formgen.pagination.first': 'Erste Seite',
|
||||
'formgen.pagination.prev': 'Vorherige Seite',
|
||||
'formgen.pagination.next': 'Nächste Seite',
|
||||
'formgen.pagination.last': 'Letzte Seite',
|
||||
'formgen.select.all': 'Alle Elemente auswählen',
|
||||
'formgen.select.item': 'Dieses Element auswählen',
|
||||
'formgen.select.disabled': 'Dieses Element kann nicht ausgewählt werden',
|
||||
'formgen.delete.multiple': 'Löschen ({count})',
|
||||
'formgen.delete.single': 'Löschen',
|
||||
'formgen.delete.confirm': 'Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?',
|
||||
'formgen.delete.confirm_multiple': 'Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?',
|
||||
'formgen.delete.confirm_single': 'Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?',
|
||||
|
||||
// Prompts
|
||||
'prompts.title': 'Prompts',
|
||||
'prompts.subtitle': 'Prompts verwalten',
|
||||
'prompts.description': 'Prompts für Ihren KI-Assistenten erstellen und verwalten',
|
||||
'prompts.new_button': 'Neuer Prompt',
|
||||
'prompts.addNew': 'Prompt hinzufügen',
|
||||
'prompts.creating': 'Erstellen...',
|
||||
'prompts.column.name': 'Name',
|
||||
'prompts.column.content': 'Inhalt',
|
||||
'prompts.column.mandateId': 'Mandat-ID',
|
||||
'prompts.unnamed': 'Unbenannt',
|
||||
'prompts.action.edit': 'Bearbeiten',
|
||||
'prompts.action.copy': 'Kopieren',
|
||||
'prompts.action.delete': 'Löschen',
|
||||
'prompts.action.delete.disabled': 'Keine Berechtigung zum Löschen des Prompts',
|
||||
'prompts.delete.confirm': 'Sind Sie sicher, dass Sie "{name}" löschen möchten?',
|
||||
'prompts.delete.confirmMultiple': 'Sind Sie sicher, dass Sie {count} Prompts löschen möchten?',
|
||||
'prompts.field.name': 'Prompt-Name',
|
||||
'prompts.field.content': 'Prompt-Inhalt',
|
||||
'prompts.validation.nameRequired': 'Prompt-Name darf nicht leer sein',
|
||||
'prompts.validation.nameTooLong': 'Prompt-Name darf 100 Zeichen nicht überschreiten',
|
||||
'prompts.validation.contentRequired': 'Prompt-Inhalt darf nicht leer sein',
|
||||
'prompts.validation.contentTooLong': 'Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten',
|
||||
'prompts.error.loading': 'Fehler beim Laden der Prompts:',
|
||||
'prompts.modal.edit.title': 'Prompt bearbeiten',
|
||||
'prompts.modal.edit.save': 'Änderungen speichern',
|
||||
'prompts.modal.create.title': 'Neuen Prompt erstellen',
|
||||
'prompts.modal.create.save': 'Prompt erstellen',
|
||||
'prompts.create.success': 'Prompt erfolgreich erstellt',
|
||||
'prompts.create.error': 'Fehler beim Erstellen des Prompts',
|
||||
|
||||
// Users/Members
|
||||
'users.title': 'Benutzer',
|
||||
'users.column.username': 'Benutzername',
|
||||
'users.column.name': 'Name',
|
||||
'users.column.email': 'E-Mail',
|
||||
'users.column.password': 'Passwort',
|
||||
'users.column.language': 'Sprache',
|
||||
'users.column.privilege': 'Berechtigung',
|
||||
'users.column.enabled': 'Aktiviert',
|
||||
'users.column.authAuthority': 'Auth-Anbieter',
|
||||
'users.password.placeholder': 'Passwort eingeben',
|
||||
'users.noUsername': 'Kein Benutzername',
|
||||
'users.noName': 'Kein Name',
|
||||
'users.noEmail': 'Keine E-Mail',
|
||||
'users.noLanguage': 'Keine Sprache',
|
||||
'users.noPrivilege': 'Keine Berechtigung',
|
||||
'users.noAuthAuthority': 'Kein Auth-Anbieter',
|
||||
'users.privilege.viewer': 'Betrachter',
|
||||
'users.privilege.user': 'Benutzer',
|
||||
'users.privilege.admin': 'Administrator',
|
||||
'users.privilege.sysadmin': 'Systemadministrator',
|
||||
'users.enabled.yes': 'Ja',
|
||||
'users.enabled.no': 'Nein',
|
||||
'users.auth.local': 'Lokal',
|
||||
'users.auth.msft': 'Microsoft',
|
||||
'users.actions.edit': 'Bearbeiten',
|
||||
'users.actions.delete': 'Löschen',
|
||||
'users.edit.title': 'Benutzer bearbeiten',
|
||||
'users.add.title': 'Benutzer hinzufügen',
|
||||
'users.add.button': 'Benutzer hinzufügen',
|
||||
'users.add.create': 'Benutzer erstellen',
|
||||
'users.delete.title': 'Benutzer löschen',
|
||||
'users.delete.message': 'Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?',
|
||||
'users.delete.confirm': 'Sind Sie sicher, dass Sie "{name}" löschen möchten?',
|
||||
'users.delete.warning': 'Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'users.action.edit': 'Bearbeiten',
|
||||
'users.action.delete': 'Löschen',
|
||||
'users.delete.confirmMultiple': 'Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?',
|
||||
'users.error.loading': 'Fehler beim Laden der Benutzer:',
|
||||
|
||||
// Team Members
|
||||
'team-members.title': 'Team-Mitglieder',
|
||||
'team-members.subtitle': 'Team-Mitglieder verwalten',
|
||||
'team-members.description': 'Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren',
|
||||
'team-members.new_button': 'Mitglied hinzufügen',
|
||||
'team-members.action.edit': 'Bearbeiten',
|
||||
'team-members.action.delete': 'Löschen',
|
||||
'team-members.action.sendPasswordLink': 'Passwort-Link senden',
|
||||
'team-members.action.passwordLinkSent': 'Passwort-Link gesendet!',
|
||||
'team-members.action.passwordLinkFailed': 'Link konnte nicht gesendet werden',
|
||||
'team-members.field.username': 'Benutzername',
|
||||
'team-members.field.email': 'E-Mail',
|
||||
'team-members.field.password': 'Passwort',
|
||||
'team-members.field.fullName': 'Vollständiger Name',
|
||||
'team-members.field.privilege': 'Berechtigung',
|
||||
'team-members.modal.create.title': 'Neues Team-Mitglied erstellen',
|
||||
'team-members.create.success': 'Team-Mitglied erfolgreich erstellt',
|
||||
'team-members.create.error': 'Fehler beim Erstellen des Team-Mitglieds',
|
||||
|
||||
// SharePoint Test
|
||||
'sharepoint.title': 'SharePoint Test',
|
||||
'sharepoint.table.title': 'SharePoint Dokumente',
|
||||
'sharepoint.error.loading': 'Fehler beim Laden der SharePoint Dokumente:',
|
||||
'sharepoint.button.retry': 'Wiederholen',
|
||||
'sharepoint.button.testConnection': 'Verbindung testen',
|
||||
'sharepoint.button.listDocuments': 'Dokumente auflisten',
|
||||
'sharepoint.button.discoverSites': 'Sites entdecken',
|
||||
'sharepoint.column.documentName': 'Dokumentname',
|
||||
'sharepoint.column.mimeType': 'MIME-Typ',
|
||||
'sharepoint.column.size': 'Größe',
|
||||
'sharepoint.column.path': 'Pfad',
|
||||
'sharepoint.action.view': 'Anzeigen',
|
||||
'sharepoint.action.download': 'Herunterladen',
|
||||
'sharepoint.connections.title': 'Microsoft Verbindungen',
|
||||
'sharepoint.connections.noConnections': 'Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.',
|
||||
'sharepoint.connections.loading': 'Verbindungen werden geladen...',
|
||||
'sharepoint.sites.discovered': 'Entdeckte Sites',
|
||||
'sharepoint.sites.noSites': 'Keine SharePoint-Sites gefunden',
|
||||
'sharepoint.sites.authError': 'Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.',
|
||||
'sharepoint.sites.retryConnection': 'Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.',
|
||||
'sharepoint.form.siteUrl': 'SharePoint Site URL',
|
||||
'sharepoint.form.folderPaths': 'Ordnerpfade',
|
||||
|
||||
// Speech
|
||||
'speech.title': 'Sprach Integration',
|
||||
'speech.subtitle': 'Unterstützt von',
|
||||
'speech.signup.title': 'Sprach Integration',
|
||||
'speech.signup.subtitle': 'Unterstützt von',
|
||||
|
||||
'speech.info.va': 'Virtual Assistant (VA)',
|
||||
'speech.info.va_description': 'Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.',
|
||||
'speech.info.sa': 'Speech Analytics (SA)',
|
||||
'speech.info.sa_description': 'Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.',
|
||||
'speech.info.vb': 'Voice Biometrics (VB)',
|
||||
'speech.info.vb_description': 'Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.',
|
||||
'speech.info.ka': 'Knowledge Agent (KA)',
|
||||
'speech.info.ka_description': 'Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.',
|
||||
'speech.info.cp': 'Chat Platform (CP)',
|
||||
'speech.info.cp_description': 'Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.',
|
||||
'speech.info.aa': 'Agent Assist (AA)',
|
||||
'speech.info.aa_description': 'Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.',
|
||||
|
||||
'speech.info.about': 'Revolutionäre Telefonie-Integration mit Spitch.ai',
|
||||
'speech.info.about_intro': 'Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.',
|
||||
'speech.info.workflow_title': 'Nahtloser Mandanten-Workflow:',
|
||||
'speech.info.workflow_description': 'Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.',
|
||||
'speech.info.ai_title': 'KI-gestützte Dokumentengenerierung:',
|
||||
'speech.info.ai_description': 'Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.',
|
||||
'speech.info.sync_title': 'Echtzeit-Datensynchronisation:',
|
||||
'speech.info.sync_description': 'Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.',
|
||||
'speech.info.cost_title': 'Kosteneinsparungen & Effizienz:',
|
||||
'speech.info.cost_description': 'Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.',
|
||||
'speech.info.about_link': 'Mehr erfahren',
|
||||
|
||||
'speech.signup.button': 'Verbinden',
|
||||
'speech.signup.back': 'Zurück zur Sprach Integration',
|
||||
'speech.signup.submit': 'Mandat erstellen',
|
||||
'speech.signup.cancel': 'Abbrechen',
|
||||
|
||||
'speech.signup.company_info': 'Unternehmensinformationen',
|
||||
'speech.signup.company_name': 'Firmenname',
|
||||
'speech.signup.company_name_placeholder': 'Geben Sie Ihren Firmennamen ein',
|
||||
'speech.signup.industry': 'Branche',
|
||||
'speech.signup.industry_placeholder': 'z.B. Finanzdienstleistungen, Technologie, etc.',
|
||||
'speech.signup.business_hours': 'Geschäftszeiten',
|
||||
'speech.signup.timezone': 'Zeitzone',
|
||||
|
||||
'speech.signup.contact_info': 'Kontaktinformationen',
|
||||
'speech.signup.email': 'E-Mail-Adresse',
|
||||
'speech.signup.email_placeholder': 'kontakt@firma.com',
|
||||
'speech.signup.phone': 'Telefonnummer',
|
||||
'speech.signup.phone_placeholder': '+41 123 456 789',
|
||||
'speech.signup.street': 'Straße',
|
||||
'speech.signup.postal_code': 'Postleitzahl',
|
||||
'speech.signup.city': 'Stadt',
|
||||
'speech.signup.country': 'Land',
|
||||
|
||||
'speech.signup.contacts_setup': 'Kontakte einrichten',
|
||||
'speech.signup.contacts_description': 'Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.',
|
||||
'speech.signup.setup_contacts': 'Kontakte einrichten',
|
||||
'speech.signup.skip_for_now': 'Jetzt überspringen',
|
||||
|
||||
'speech.signup.company_required': 'Firmenname ist erforderlich',
|
||||
'speech.signup.industry_required': 'Branche ist erforderlich',
|
||||
'speech.signup.email_required': 'E-Mail-Adresse ist erforderlich',
|
||||
'speech.signup.email_invalid': 'Bitte geben Sie eine gültige E-Mail-Adresse ein',
|
||||
'speech.signup.phone_required': 'Telefonnummer ist erforderlich',
|
||||
'speech.signup.street_required': 'Straße ist erforderlich',
|
||||
'speech.signup.postal_code_required': 'Postleitzahl ist erforderlich',
|
||||
'speech.signup.city_required': 'Stadt ist erforderlich',
|
||||
'speech.signup.country_required': 'Land ist erforderlich',
|
||||
|
||||
'speech.status.submitted': '✓ Mandat eingereicht',
|
||||
'speech.status.reset': 'Neu starten',
|
||||
|
||||
'speech.confirmation.title': 'Mandat erfolgreich eingereicht!',
|
||||
'speech.confirmation.message': 'Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.',
|
||||
'speech.confirmation.submitted_data': 'Eingereichte Daten:',
|
||||
'speech.confirmation.company': 'Firma',
|
||||
'speech.confirmation.industry': 'Branche',
|
||||
'speech.confirmation.email': 'E-Mail',
|
||||
'speech.confirmation.phone': 'Telefon',
|
||||
'speech.confirmation.address': 'Adresse',
|
||||
'speech.confirmation.timezone': 'Zeitzone',
|
||||
'speech.confirmation.back': 'Zurück zur Sprach Integration',
|
||||
'speech.confirmation.reset': 'Neu starten',
|
||||
'speech.confirmation.next_steps': 'Was passiert als nächstes?',
|
||||
'speech.confirmation.email_confirmation': 'E-Mail-Bestätigung',
|
||||
'speech.confirmation.email_confirmation_desc': 'Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.',
|
||||
'speech.confirmation.review_process': 'Überprüfungsprozess',
|
||||
'speech.confirmation.review_process_desc': 'Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.',
|
||||
'speech.confirmation.setup_call': 'Einrichtungsanruf',
|
||||
'speech.confirmation.setup_call_desc': 'Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.',
|
||||
'speech.confirmation.questions': 'Fragen?',
|
||||
'speech.confirmation.questions_desc': 'Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.',
|
||||
'speech.confirmation.transcript_management': 'Transkriptverwaltung',
|
||||
'speech.confirmation.speech_settings': 'Sprach-Einstellungen',
|
||||
|
||||
'speech.transcripts.title': 'Transkriptverwaltung',
|
||||
'speech.transcripts.new_transcript': 'Neues Transkript',
|
||||
'speech.transcripts.recent_transcripts': 'Aktuelle Transkripte',
|
||||
'speech.transcripts.no_transcripts': 'Keine Transkripte vorhanden',
|
||||
'speech.transcripts.date': 'Datum',
|
||||
'speech.transcripts.duration': 'Dauer',
|
||||
'speech.transcripts.status': 'Status',
|
||||
'speech.transcripts.transcript': 'Transkript',
|
||||
'speech.transcripts.processing': 'Transkript wird verarbeitet...',
|
||||
'speech.transcripts.status.completed': 'Abgeschlossen',
|
||||
'speech.transcripts.status.processing': 'Verarbeitung',
|
||||
'speech.transcripts.status.failed': 'Fehlgeschlagen',
|
||||
'speech.transcripts.access_denied_title': 'Zugriff verweigert',
|
||||
'speech.transcripts.access_denied_message': 'Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.',
|
||||
'speech.transcripts.sign_up_now': 'Jetzt anmelden',
|
||||
'speech.transcripts.subject': 'Betreff',
|
||||
'speech.transcripts.start_time': 'Startzeit',
|
||||
'speech.transcripts.end_time': 'Endzeit',
|
||||
'speech.transcripts.caller': 'Anrufer',
|
||||
'speech.transcripts.recipient': 'Empfänger',
|
||||
'speech.transcripts.tags': 'Tags',
|
||||
'speech.transcripts.created': 'Erstellt',
|
||||
'speech.transcripts.view': 'Anzeigen',
|
||||
'speech.transcripts.download': 'Herunterladen',
|
||||
|
||||
'speech.settings.title': 'Sprach-Integration Einstellungen',
|
||||
'speech.settings.description': 'Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.',
|
||||
'speech.settings.company_info': 'Unternehmensinformationen',
|
||||
'speech.settings.contact_info': 'Kontaktinformationen',
|
||||
'speech.settings.business_hours': 'Geschäftszeiten & Zeitzone',
|
||||
'speech.settings.save': 'Änderungen speichern',
|
||||
'speech.settings.saving': 'Speichern...',
|
||||
'speech.settings.save_success': 'Einstellungen erfolgreich gespeichert!',
|
||||
'speech.settings.save_error': 'Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.',
|
||||
'speech.settings.reset': 'Auf Standard zurücksetzen',
|
||||
'speech.settings.reset_confirm': 'Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'speech.settings.reset_success': 'Einstellungen wurden erfolgreich zurückgesetzt.',
|
||||
'speech.settings.no_data': 'Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.',
|
||||
'speech.settings.sign_up_now': 'Jetzt anmelden',
|
||||
|
||||
// Message Overlay Types
|
||||
'message.success.title': 'Erfolgreich',
|
||||
'message.success.upload': 'Datei erfolgreich hochgeladen!',
|
||||
'message.info.title': 'Information',
|
||||
'message.info.processing': 'Ihre Anfrage wird verarbeitet...',
|
||||
'message.error.title': 'Fehler',
|
||||
'message.error.upload_failed': 'Upload fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
||||
|
||||
// Warning Messages
|
||||
'warning.duplicate_file.title': 'Datei bereits vorhanden',
|
||||
'warning.duplicate_file.message': 'Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.',
|
||||
|
||||
// Automations Page
|
||||
'automations.title': 'Automatisierungen',
|
||||
'automations.description': 'Workflow-Automatisierungen verwalten',
|
||||
'automations.subtitle': 'Geplante und automatisierte Workflows',
|
||||
'automations.new_button': 'Neue Automatisierung',
|
||||
'automations.action.execute': 'Ausführen',
|
||||
'automations.action.edit': 'Bearbeiten',
|
||||
'automations.action.delete': 'Löschen',
|
||||
'automations.modal.create.title': 'Neue Automatisierung erstellen',
|
||||
'automations.create.success': 'Automatisierung erfolgreich erstellt',
|
||||
'automations.create.error': 'Fehler beim Erstellen der Automatisierung',
|
||||
|
||||
// Basedata Group (formerly Administration)
|
||||
'basedata.title': 'Basisdaten',
|
||||
'basedata.description': 'Grundlegende Daten und Ressourcen',
|
||||
|
||||
// Administration (legacy, kept for compatibility)
|
||||
'administration.title': 'Werkzeuge',
|
||||
'administration.description': 'Werkzeuge und Hilfsmittel',
|
||||
'administration.subtitle': 'Verwaltungs- und Management-Tools',
|
||||
'administration.intro.description': 'Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.',
|
||||
'administration.features.title': 'Verfügbare Tools',
|
||||
'administration.features.description': 'Management-Tools umfassen:',
|
||||
'administration.features.file_management': 'Dateiverwaltung - Dokumente hochladen und organisieren',
|
||||
'administration.features.user_management': 'Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten',
|
||||
'administration.features.system_settings': 'Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren',
|
||||
'administration.features.data_management': 'Datenverwaltung - Datenimporte und -exporte verwalten',
|
||||
|
||||
// Admin pages
|
||||
'admin.mandates.title': 'Mandate',
|
||||
'admin.mandates.subtitle': 'Mandate und Berechtigungen verwalten',
|
||||
'admin.mandates.description': 'Mandatsverwaltung',
|
||||
'admin.mandates.description_text': 'Verwalten Sie Mandate und deren zugehörige Berechtigungen.',
|
||||
'admin.mandates.new_button': 'Mandat hinzufügen',
|
||||
'admin.mandates.action.edit': 'Bearbeiten',
|
||||
'admin.mandates.action.delete': 'Löschen',
|
||||
'admin.mandates.modal.create.title': 'Neues Mandat erstellen',
|
||||
'admin.mandates.create.success': 'Mandat erfolgreich erstellt',
|
||||
'admin.mandates.create.error': 'Fehler beim Erstellen des Mandats',
|
||||
|
||||
'admin.rbac-rules.title': 'RBAC-Regeln',
|
||||
'admin.rbac-rules.subtitle': 'Rollenbasierte Zugriffssteuerungsregeln',
|
||||
'admin.rbac-rules.description': 'RBAC-Regelverwaltung',
|
||||
'admin.rbac-rules.description_text': 'Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.',
|
||||
'admin.rbac-rules.new_button': 'RBAC-Regel hinzufügen',
|
||||
'admin.rbac-rules.action.edit': 'Bearbeiten',
|
||||
'admin.rbac-rules.action.delete': 'Löschen',
|
||||
'admin.rbac-rules.modal.create.title': 'Neue RBAC-Regel erstellen',
|
||||
'admin.rbac-rules.create.success': 'RBAC-Regel erfolgreich erstellt',
|
||||
'admin.rbac-rules.create.error': 'Fehler beim Erstellen der RBAC-Regel',
|
||||
|
||||
'admin.rbac-role.title': 'RBAC-Rollen',
|
||||
'admin.rbac-role.subtitle': 'Rollenverwaltung',
|
||||
'admin.rbac-role.description': 'RBAC-Rollenverwaltung',
|
||||
'admin.rbac-role.description_text': 'Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.',
|
||||
'admin.rbac-role.new_button': 'Rolle hinzufügen',
|
||||
'admin.rbac-role.action.edit': 'Bearbeiten',
|
||||
'admin.rbac-role.action.delete': 'Löschen',
|
||||
'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen',
|
||||
'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt',
|
||||
'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle',
|
||||
|
||||
'admin.admin-settings.title': 'Admin-Einstellungen',
|
||||
'admin.admin-settings.subtitle': 'Administrative Einstellungen',
|
||||
'admin.admin-settings.description': 'Administrative Einstellungen',
|
||||
'admin.admin-settings.description_text': 'Konfigurieren Sie administrative Einstellungen und Systempräferenzen.',
|
||||
|
||||
// Start page
|
||||
'start.title': 'Start',
|
||||
'start.description': 'Willkommen in Ihrem Arbeitsbereich',
|
||||
'start.subtitle': 'Willkommen in Ihrem Arbeitsbereich',
|
||||
'start.intro.description': 'Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.',
|
||||
'start.features.title': 'Schnellzugriff',
|
||||
'start.features.description': 'Beginnen Sie mit:',
|
||||
'start.features.quick_access': 'Schnellzugriff - Springen Sie zu häufig verwendeten Features',
|
||||
'start.features.recent_activities': 'Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit',
|
||||
'start.features.overview': 'Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates',
|
||||
'start.features.navigation': 'Navigation - Erkunden Sie alle verfügbaren Tools',
|
||||
|
||||
// Projects page
|
||||
'projects.title': 'Projekte',
|
||||
'projects.subtitle': 'Projektverwaltung',
|
||||
'projects.description': 'Projektverwaltung und -organisation',
|
||||
'projects.description_text': 'Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.',
|
||||
'projects.command.placeholder': 'Befehl eingeben (z.B., "Erstelle ein neues Projekt namens \'Hauptstrasse 42\'")',
|
||||
'projects.command.empty': 'Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.',
|
||||
|
||||
// Data Management page
|
||||
'data-management.title': 'Datenverwaltung',
|
||||
'data-management.subtitle': 'Datenverwaltung',
|
||||
'data-management.description': 'Datenverwaltung mit Tabellen',
|
||||
'data-management.description_text': 'Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.',
|
||||
'data-management.command.placeholder': 'Befehl eingeben (z.B., "Erstelle ein neues Projekt namens \'Hauptstrasse 42\'")',
|
||||
'data-management.command.empty': 'Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.',
|
||||
|
||||
// Drag and Drop
|
||||
'dragdrop.overlay.default_text': 'Dateien hier ablegen',
|
||||
'dragdrop.overlay.default_subtext': 'Sie können auch auf den Upload-Button klicken',
|
||||
'dragdrop.overlay.processing': 'Dateien werden verarbeitet...',
|
||||
'dragdrop.overlay.error': 'Fehler beim Verarbeiten der Dateien',
|
||||
|
||||
// Trustee Feature
|
||||
'trustee.title': 'Treuhand',
|
||||
'trustee.subtitle': 'Treuhandverwaltung',
|
||||
'trustee.description': 'Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen',
|
||||
|
||||
// Trustee Organisations
|
||||
'trustee.organisations.title': 'Organisationen',
|
||||
'trustee.organisations.subtitle': 'Trustee-Organisationen verwalten',
|
||||
'trustee.organisations.description': 'Verwaltung der Treuhand-Organisationen',
|
||||
'trustee.organisations.new_button': 'Neue Organisation',
|
||||
'trustee.organisations.field.id': 'ID',
|
||||
'trustee.organisations.field.id_placeholder': 'z.B. treuhand-ag-zuerich',
|
||||
'trustee.organisations.field.label': 'Bezeichnung',
|
||||
'trustee.organisations.field.label_placeholder': 'z.B. Treuhand AG Zürich',
|
||||
'trustee.organisations.field.enabled': 'Aktiviert',
|
||||
'trustee.organisations.modal.create.title': 'Neue Organisation erstellen',
|
||||
'trustee.organisations.create.success': 'Organisation erfolgreich erstellt',
|
||||
'trustee.organisations.create.error': 'Fehler beim Erstellen der Organisation',
|
||||
'trustee.organisations.action.edit': 'Bearbeiten',
|
||||
'trustee.organisations.action.delete': 'Löschen',
|
||||
|
||||
// Trustee Roles
|
||||
'trustee.roles.title': 'Rollen',
|
||||
'trustee.roles.subtitle': 'Trustee-Rollen verwalten',
|
||||
'trustee.roles.description': 'Verwaltung der Feature-spezifischen Rollen',
|
||||
'trustee.roles.new_button': 'Neue Rolle',
|
||||
'trustee.roles.field.id': 'Rollen-ID',
|
||||
'trustee.roles.field.id_placeholder': 'z.B. admin, operate, userreport',
|
||||
'trustee.roles.field.desc': 'Beschreibung',
|
||||
'trustee.roles.field.desc_placeholder': 'Beschreibung der Rolle',
|
||||
'trustee.roles.modal.create.title': 'Neue Rolle erstellen',
|
||||
'trustee.roles.create.success': 'Rolle erfolgreich erstellt',
|
||||
'trustee.roles.create.error': 'Fehler beim Erstellen der Rolle',
|
||||
'trustee.roles.action.edit': 'Bearbeiten',
|
||||
'trustee.roles.action.delete': 'Löschen',
|
||||
|
||||
// Trustee Access
|
||||
'trustee.access.title': 'Zugriff',
|
||||
'trustee.access.subtitle': 'Benutzer-Zugriff verwalten',
|
||||
'trustee.access.description': 'Verwaltung der Benutzerzugriffe auf Organisationen',
|
||||
'trustee.access.new_button': 'Neuer Zugriff',
|
||||
'trustee.access.field.organisationId': 'Organisation',
|
||||
'trustee.access.field.roleId': 'Rolle',
|
||||
'trustee.access.field.userId': 'Benutzer',
|
||||
'trustee.access.field.contractId': 'Vertrag (optional)',
|
||||
'trustee.access.field.contractId_placeholder': 'Leer = Zugriff auf alle Verträge',
|
||||
'trustee.access.modal.create.title': 'Neuen Zugriff erstellen',
|
||||
'trustee.access.create.success': 'Zugriff erfolgreich erstellt',
|
||||
'trustee.access.create.error': 'Fehler beim Erstellen des Zugriffs',
|
||||
'trustee.access.action.edit': 'Bearbeiten',
|
||||
'trustee.access.action.delete': 'Löschen',
|
||||
|
||||
// Trustee Contracts
|
||||
'trustee.contracts.title': 'Verträge',
|
||||
'trustee.contracts.subtitle': 'Kundenverträge verwalten',
|
||||
'trustee.contracts.description': 'Verwaltung der Kundenverträge',
|
||||
'trustee.contracts.new_button': 'Neuer Vertrag',
|
||||
'trustee.contracts.field.organisationId': 'Organisation',
|
||||
'trustee.contracts.field.label': 'Bezeichnung',
|
||||
'trustee.contracts.field.label_placeholder': 'z.B. Muster AG 2026',
|
||||
'trustee.contracts.field.enabled': 'Aktiviert',
|
||||
'trustee.contracts.modal.create.title': 'Neuen Vertrag erstellen',
|
||||
'trustee.contracts.create.success': 'Vertrag erfolgreich erstellt',
|
||||
'trustee.contracts.create.error': 'Fehler beim Erstellen des Vertrags',
|
||||
'trustee.contracts.action.edit': 'Bearbeiten',
|
||||
'trustee.contracts.action.delete': 'Löschen',
|
||||
|
||||
// Trustee Documents
|
||||
'trustee.documents.title': 'Dokumente',
|
||||
'trustee.documents.subtitle': 'Belege verwalten',
|
||||
'trustee.documents.description': 'Verwaltung der Dokumente und Belege',
|
||||
'trustee.documents.new_button': 'Neues Dokument',
|
||||
'trustee.documents.field.organisationId': 'Organisation',
|
||||
'trustee.documents.field.contractId': 'Vertrag',
|
||||
'trustee.documents.field.documentName': 'Dateiname',
|
||||
'trustee.documents.field.documentName_placeholder': 'z.B. Beleg.pdf',
|
||||
'trustee.documents.field.documentMimeType': 'Dateityp',
|
||||
'trustee.documents.modal.create.title': 'Neues Dokument erstellen',
|
||||
'trustee.documents.create.success': 'Dokument erfolgreich erstellt',
|
||||
'trustee.documents.create.error': 'Fehler beim Erstellen des Dokuments',
|
||||
'trustee.documents.action.edit': 'Bearbeiten',
|
||||
'trustee.documents.action.delete': 'Löschen',
|
||||
'trustee.documents.action.download': 'Herunterladen',
|
||||
|
||||
// Trustee Positions
|
||||
'trustee.positions.title': 'Positionen',
|
||||
'trustee.positions.subtitle': 'Buchungspositionen verwalten',
|
||||
'trustee.positions.description': 'Verwaltung der Buchungspositionen (Speseneinträge)',
|
||||
'trustee.positions.new_button': 'Neue Position',
|
||||
'trustee.positions.field.organisationId': 'Organisation',
|
||||
'trustee.positions.field.contractId': 'Vertrag',
|
||||
'trustee.positions.field.valuta': 'Valutadatum',
|
||||
'trustee.positions.field.company': 'Firma',
|
||||
'trustee.positions.field.company_placeholder': 'Name des Unternehmens',
|
||||
'trustee.positions.field.desc': 'Beschreibung',
|
||||
'trustee.positions.field.bookingCurrency': 'Buchungswährung',
|
||||
'trustee.positions.field.bookingAmount': 'Buchungsbetrag',
|
||||
'trustee.positions.field.originalCurrency': 'Originalwährung',
|
||||
'trustee.positions.field.originalAmount': 'Originalbetrag',
|
||||
'trustee.positions.field.vatPercentage': 'MwSt %',
|
||||
'trustee.positions.field.vatAmount': 'MwSt Betrag',
|
||||
'trustee.positions.modal.create.title': 'Neue Position erstellen',
|
||||
'trustee.positions.create.success': 'Position erfolgreich erstellt',
|
||||
'trustee.positions.create.error': 'Fehler beim Erstellen der Position',
|
||||
'trustee.positions.action.edit': 'Bearbeiten',
|
||||
'trustee.positions.action.delete': 'Löschen',
|
||||
};
|
||||
|
|
@ -1,928 +0,0 @@
|
|||
export default {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.files': 'Files',
|
||||
'nav.team': 'Team Area',
|
||||
'nav.workflows': 'Workflows',
|
||||
'nav.connections': 'Connections',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.testSharepoint': 'Test SharePoint',
|
||||
'nav.speech': 'Speech',
|
||||
'nav.transcript_management': 'Transcript Management',
|
||||
|
||||
// Settings page
|
||||
'settings.title': 'Settings',
|
||||
'settings.appearance': 'Appearance',
|
||||
'settings.language': 'Language',
|
||||
'settings.about': 'About',
|
||||
'settings.version': 'Version',
|
||||
'settings.theme': 'Theme',
|
||||
'settings.theme.description': 'Switch between light and dark mode',
|
||||
'settings.language.description': 'Choose your preferred language',
|
||||
'settings.theme.light': 'Light',
|
||||
'settings.theme.dark': 'Dark',
|
||||
'settings.theme.toggle.light': 'Switch to light mode',
|
||||
'settings.theme.toggle.dark': 'Switch to dark mode',
|
||||
'settings.userinfo': 'User Information',
|
||||
'settings.userinfo.description': 'Manage your account information',
|
||||
'settings.userinfo.username': 'Username',
|
||||
'settings.userinfo.fullname': 'Full Name',
|
||||
'settings.userinfo.email': 'Email Address',
|
||||
'settings.userinfo.phone_name': 'Phone Name',
|
||||
'settings.userinfo.phone_name.description': 'How would you like to be called on the phone?',
|
||||
'settings.userinfo.language': 'Language',
|
||||
'settings.userinfo.privilege': 'Privilege Level',
|
||||
'settings.userinfo.enabled': 'Account Status',
|
||||
'settings.userinfo.auth_authority': 'Authentication Provider',
|
||||
'settings.userinfo.enabled.true': 'Active',
|
||||
'settings.userinfo.enabled.false': 'Inactive',
|
||||
'settings.userinfo.loading': 'Loading user information...',
|
||||
'settings.userinfo.error': 'Error loading user information',
|
||||
'settings.userinfo.save': 'Save Changes',
|
||||
'settings.userinfo.saving': 'Saving...',
|
||||
'settings.userinfo.success': 'User information updated successfully',
|
||||
'settings.userinfo.update_error': 'Error updating user information',
|
||||
'settings.userinfo.managed_by': 'Managed by {provider}',
|
||||
'settings.userinfo.managed_note': 'This field is managed by {provider} and cannot be changed',
|
||||
|
||||
// Languages
|
||||
'language.german': 'Deutsch',
|
||||
'language.english': 'English',
|
||||
'language.french': 'Français',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Loading...',
|
||||
'common.error': 'Error',
|
||||
'common.success': 'Success',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.close': 'Close',
|
||||
'common.retry': 'Retry',
|
||||
'common.create': 'Create',
|
||||
'common.creating': 'Creating...',
|
||||
|
||||
// Auth
|
||||
'auth.login': 'Login',
|
||||
'auth.register': 'Register',
|
||||
'auth.logout': 'Logout',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.prompt.template': 'Prompt Template',
|
||||
'dashboard.prompt.settings': 'Settings',
|
||||
'dashboard.chat.area': 'Chat Area',
|
||||
'dashboard.chat.history': 'Workflow History',
|
||||
'dashboard.log.title': 'Log',
|
||||
'dashboard.log.workflow': 'Workflow',
|
||||
'dashboard.log.no_workflow': 'No workflow selected',
|
||||
'dashboard.log.loading': 'Loading logs...',
|
||||
'dashboard.log.error': 'Error loading logs',
|
||||
'dashboard.log.no_logs': 'No logs available for this workflow',
|
||||
'dashboard.log.waiting': 'Workflow running... Waiting for logs...',
|
||||
'dashboard.log.fetch_failed': 'Failed to fetch logs',
|
||||
'dashboard.log.level.info': 'INFO',
|
||||
'dashboard.workflow_dropdown.loading': 'Loading...',
|
||||
'dashboard.workflow_dropdown.error': 'Error',
|
||||
'dashboard.workflow_dropdown.select_workflow': 'Select Workflow',
|
||||
'dashboard.workflow_dropdown.available_workflows': 'Available Workflows',
|
||||
'dashboard.workflow_dropdown.no_workflows': 'No workflows available',
|
||||
|
||||
// Workflow Stats
|
||||
'dashboard.stats.workflow': 'Workflow',
|
||||
'dashboard.stats.status': 'Status',
|
||||
'dashboard.stats.rounds': 'Rounds',
|
||||
'dashboard.stats.messages': 'Messages',
|
||||
'dashboard.stats.files': 'Files',
|
||||
'dashboard.stats.tokens': 'Tokens',
|
||||
'dashboard.stats.data_sent': 'Data Sent',
|
||||
'dashboard.stats.data_received': 'Data Received',
|
||||
'dashboard.stats.success_rate': 'Success Rate',
|
||||
'dashboard.stats.errors': 'Errors',
|
||||
'dashboard.stats.started': 'Started',
|
||||
|
||||
// Prompt Set
|
||||
'promptset.loading': 'Loading prompts...',
|
||||
'promptset.error.loading': 'Error loading prompts',
|
||||
'promptset.retry': 'Try again',
|
||||
'promptset.new_prompt': 'New Prompt',
|
||||
'promptset.prompt_count': 'Prompt',
|
||||
'promptset.prompt_count_plural': 'Prompts',
|
||||
'promptset.no_prompts': 'No prompts available',
|
||||
'promptset.created': 'Created',
|
||||
'promptset.run_tooltip': 'Run prompt',
|
||||
'promptset.share_tooltip': 'Share prompt',
|
||||
'promptset.delete_tooltip': 'Delete prompt',
|
||||
'promptset.confirm_delete': 'Click again to confirm',
|
||||
'promptset.deleting': 'Deleting...',
|
||||
'promptset.confirm_click': 'Click to confirm',
|
||||
'promptset.delete_error': 'Error deleting',
|
||||
'promptset.deleting_message': 'Deleting prompt...',
|
||||
|
||||
// Connections
|
||||
'connections.title': 'Connections',
|
||||
'connections.subtitle': 'Manage your service connections',
|
||||
'connections.connect_google': 'Connect Google',
|
||||
'connections.connect_microsoft': 'Connect Microsoft',
|
||||
'connections.add_google_button': 'Add Google Connection',
|
||||
'connections.add_microsoft_button': 'Add Microsoft Connection',
|
||||
'connections.create_google_title': 'Create Google Connection',
|
||||
'connections.create_microsoft_title': 'Create Microsoft Connection',
|
||||
'connections.edit_connection_title': 'Edit {authority} Connection',
|
||||
'connections.update_connection': 'Update Connection',
|
||||
'connections.service_connections': 'Service Connections',
|
||||
'connections.error': 'Error',
|
||||
'connections.connection_error': 'Connection Error',
|
||||
'connections.disconnect_error': 'Disconnect Error',
|
||||
'connections.unknown': 'Unknown',
|
||||
'connections.not_available': 'N/A',
|
||||
'connections.invalid_date': 'Invalid Date',
|
||||
'connections.confirm_delete': 'Are you sure you want to delete the {service} connection?',
|
||||
'connections.confirm_delete_multiple': 'Are you sure you want to delete {count} connections?',
|
||||
|
||||
// Connection Fields
|
||||
'connections.field.service': 'Service',
|
||||
'connections.field.status': 'Status',
|
||||
'connections.field.external_username': 'External Username',
|
||||
'connections.field.external_email': 'External Email',
|
||||
'connections.field.connected_at': 'Connected At',
|
||||
'connections.field.last_checked': 'Last Checked',
|
||||
'connections.field.expires_at': 'Expires At',
|
||||
|
||||
// Connection Columns
|
||||
'connections.column.username': 'Username',
|
||||
'connections.column.email': 'Email',
|
||||
'connections.column.authority': 'Service',
|
||||
'connections.column.status': 'Status',
|
||||
'connections.column.connectedat': 'Connected At',
|
||||
'connections.column.lastchecked': 'Last Checked',
|
||||
'connections.column.expiresat': 'Expires At',
|
||||
|
||||
// Connection Services
|
||||
'connections.service.google': 'Google',
|
||||
'connections.service.microsoft': 'Microsoft',
|
||||
'connections.service.local': 'Local',
|
||||
|
||||
// Connection Placeholders
|
||||
'connections.placeholder.external_username': 'Enter external username',
|
||||
'connections.placeholder.external_email': 'Enter external email address',
|
||||
|
||||
// Connection Actions
|
||||
'connections.action.edit': 'Edit',
|
||||
'connections.action.update': 'Update',
|
||||
'connections.action.delete': 'Delete',
|
||||
'connections.action.connect': 'Connect',
|
||||
'connections.action.refresh': 'Refresh',
|
||||
|
||||
|
||||
// Prompt Modal
|
||||
'modal.create_prompt': 'Create New Prompt',
|
||||
'modal.name_required': 'Name is required',
|
||||
'modal.content_required': 'Content is required',
|
||||
'modal.create_error': 'Error creating prompt',
|
||||
'modal.name_label': 'Name',
|
||||
'modal.content_label': 'Content',
|
||||
'modal.name_placeholder': 'Enter a name for the prompt',
|
||||
'modal.content_placeholder': 'Enter the prompt content',
|
||||
'modal.cancel': 'Cancel',
|
||||
'modal.creating': 'Creating...',
|
||||
'modal.create': 'Create Prompt',
|
||||
|
||||
// Share Modal
|
||||
'share_modal.title': 'Share Prompt',
|
||||
'share_modal.select_users': 'Select Users',
|
||||
'share_modal.select_all': 'Select All',
|
||||
'share_modal.deselect_all': 'Deselect All',
|
||||
'share_modal.loading_users': 'Loading users...',
|
||||
'share_modal.error_loading_users': 'Error loading users',
|
||||
'share_modal.no_users_available': 'No users available',
|
||||
'share_modal.no_users_selected': 'Please select at least one user',
|
||||
'share_modal.one_user_selected': '1 user selected',
|
||||
'share_modal.multiple_users_selected': '{count} users selected',
|
||||
'share_modal.custom_title': 'Custom Title (optional)',
|
||||
'share_modal.title_placeholder': 'Enter a custom title',
|
||||
'share_modal.message': 'Message (optional)',
|
||||
'share_modal.message_placeholder': 'Add a message for recipients',
|
||||
'share_modal.share': 'Share',
|
||||
'share_modal.sharing': 'Sharing...',
|
||||
'share_modal.share_error': 'Error sharing prompt',
|
||||
|
||||
// Prompt Settings
|
||||
'prompt_settings.title': 'Prompt Settings',
|
||||
'prompt_settings.content_placeholder': 'Settings content will be added here in future updates.',
|
||||
|
||||
// Chat Area
|
||||
'chat.continue_conversation': 'Continue conversation...',
|
||||
'chat.enter_message': 'Enter message...',
|
||||
'chat.remove_file': 'Remove file',
|
||||
'chat.attach_file': 'Attach file',
|
||||
'chat.you': 'You',
|
||||
'chat.click_to_open': 'Click to open',
|
||||
'chat.preview_document': 'Preview document',
|
||||
'chat.download_document': 'Download document',
|
||||
'chat.workflow_failed': 'Workflow failed.',
|
||||
'chat.retry_workflow': 'Try again',
|
||||
'chat.sending_followup': 'Sending follow-up message...',
|
||||
'chat.sending_message': 'Sending message...',
|
||||
'chat.error_prefix': 'Error:',
|
||||
'chat.error_loading_messages': 'Error loading messages:',
|
||||
'chat.loading_workflow_messages': 'Loading workflow messages...',
|
||||
'chat.start_conversation': 'Start a conversation by entering a message, selecting a template, or continuing a previous workflow...',
|
||||
|
||||
// Chat Input Area
|
||||
'chat.input.continue_workflow': 'Continue the conversation...',
|
||||
'chat.input.enter_message': 'Or enter your message...',
|
||||
'chat.input.continuing_workflow': 'Continuing workflow',
|
||||
'chat.input.workflow': 'Workflow',
|
||||
'chat.input.files_attached': 'file',
|
||||
'chat.input.files_attached_plural': 'files',
|
||||
'chat.input.files_attached_label': 'attached',
|
||||
'chat.input.error_prefix': 'Error:',
|
||||
'chat.input.attach_files': 'Attach Files',
|
||||
'chat.input.sending': 'Sending...',
|
||||
'chat.input.processing': 'Processing...',
|
||||
'chat.input.continue': 'Continue',
|
||||
'chat.input.send': 'Send',
|
||||
'chat.input.stop': 'Stop',
|
||||
'chat.input.stopping': 'Stopping...',
|
||||
'chat.input.drop_files_here': 'Drop files here to attach',
|
||||
'chat.input.drop_disabled': 'File drop disabled during workflow',
|
||||
'chat.input.new_chat': 'New Chat',
|
||||
'chat.input.using_prompt': 'Using prompt:',
|
||||
'chat.input.select_prompt': 'Select a prompt...',
|
||||
'chat.input.loading_prompts': 'Loading prompts...',
|
||||
'chat.input.clear_prompt': 'Clear prompt',
|
||||
|
||||
// File Preview
|
||||
'file_preview.loading': 'Loading preview...',
|
||||
'file_preview.error': 'Error',
|
||||
'file_preview.no_preview': 'No preview available',
|
||||
'file_preview.close_preview': 'Close preview',
|
||||
'file_preview.python': 'Python',
|
||||
|
||||
// Chat History
|
||||
'chat_history.loading': 'Loading workflows...',
|
||||
'chat_history.error_loading': 'Error loading workflows:',
|
||||
'chat_history.try_again': 'Try Again',
|
||||
'chat_history.title': 'Workflow History',
|
||||
'chat_history.workflow_count': 'Workflow',
|
||||
'chat_history.workflow_count_plural': 'Workflows',
|
||||
'chat_history.empty_state': 'No workflows available',
|
||||
'chat_history.confirm_delete': 'Are you sure you want to delete workflow "{id}..."?',
|
||||
'chat_history.no_message_content': 'No message content available',
|
||||
'chat_history.unknown_date': 'Unknown date',
|
||||
'chat_history.invalid_date': 'Invalid date',
|
||||
'chat_history.started': 'Started:',
|
||||
'chat_history.last_activity': 'Last Activity:',
|
||||
'chat_history.round': 'Round',
|
||||
'chat_history.resume_tooltip': 'Resume workflow',
|
||||
'chat_history.delete_tooltip': 'Delete workflow',
|
||||
'chat_history.deleting': 'Deleting workflow...',
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'No workflow selected',
|
||||
'chat.messages.no_workflow_selected_description': 'Select a workflow from the list or start a new workflow',
|
||||
'chat.messages.loading_progress': 'Loading progress...',
|
||||
'chat.messages.tasks': 'Tasks',
|
||||
'chat.messages.workflow_progress': 'Workflow Progress',
|
||||
'chat.messages.analyzing_workflow': 'Analyzing workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Scroll to bottom',
|
||||
// Workflow Status
|
||||
'status.error': 'ERROR',
|
||||
'status.failed': 'FAILED',
|
||||
'status.stopped': 'STOPPED',
|
||||
'status.cancelled': 'CANCELLED',
|
||||
'status.running': 'RUNNING',
|
||||
'status.processing': 'PROCESSING',
|
||||
'status.completed': 'COMPLETED',
|
||||
'status.pending': 'PENDING',
|
||||
|
||||
// Files
|
||||
'files.unknown_size': 'Unknown Size',
|
||||
'files.unknown_date': 'Unknown Date',
|
||||
'files.source.uploaded': 'Uploaded',
|
||||
'files.source.ai_created': 'AI-created',
|
||||
'files.source.shared': 'Shared',
|
||||
'files.source.unknown': 'Unknown',
|
||||
'files.preview_tooltip': 'Preview file',
|
||||
'files.download_tooltip': 'Download file',
|
||||
'files.delete_tooltip': 'Delete file',
|
||||
'files.delete_confirm_tooltip': 'Click again to confirm deletion',
|
||||
'files.downloading': 'Downloading...',
|
||||
'files.deleting': 'Deleting...',
|
||||
'files.delete_confirm': 'Click to confirm...',
|
||||
'files.no_files': 'No files found.',
|
||||
'files.no_shared_files': 'No shared files found.',
|
||||
'files.no_ai_files': 'No AI-created files found.',
|
||||
'files.no_uploaded_files': 'No uploaded files found.',
|
||||
'files.header.name': 'Name',
|
||||
'files.header.type': 'Type',
|
||||
'files.header.size': 'Size',
|
||||
'files.header.date': 'Date',
|
||||
'files.selector.title': 'Select files',
|
||||
'files.selector.tab.all': 'All files',
|
||||
'files.selector.tab.uploads': 'Uploaded',
|
||||
'files.selector.tab.created': 'AI-created',
|
||||
'files.selector.tab.shared': 'Shared',
|
||||
'files.selector.select_all': 'Select all',
|
||||
'files.selector.deselect_all': 'Deselect all',
|
||||
'files.selector.file_selected': 'File',
|
||||
'files.selector.files_selected': 'Files',
|
||||
'files.selector.selected_suffix': 'selected',
|
||||
'files.selector.upload_new': 'Upload new file',
|
||||
'files.selector.loading': 'Loading files...',
|
||||
'files.selector.error_loading': 'Error loading files:',
|
||||
'files.upload.title': 'Upload file',
|
||||
'files.upload.drop_here': 'Drop file here...',
|
||||
'files.upload.uploading': 'Uploading...',
|
||||
'files.upload.drag_files': 'Drag files here',
|
||||
'files.upload.or': 'or',
|
||||
'files.upload.browse': 'Browse',
|
||||
'files.upload.selected_file': 'Selected file:',
|
||||
'files.upload.upload_button': 'Upload',
|
||||
'files.upload.uploading_button': 'Uploading...',
|
||||
'files.upload.success': 'File uploaded successfully!',
|
||||
'files.upload.error': 'An error occurred while uploading.',
|
||||
'files.upload.unexpected_error': 'An unexpected error occurred while uploading.',
|
||||
|
||||
// Files Page Upload Actions
|
||||
'files.drop_zone': 'Drop files here',
|
||||
'files.upload_button': 'Upload Files',
|
||||
'files.uploading_button': 'Uploading...',
|
||||
'files.upload_aria_label': 'Upload files',
|
||||
|
||||
// Files Page
|
||||
'files.title': 'Files',
|
||||
'files.table.title': 'Files',
|
||||
'files.error.loading': 'Error loading files:',
|
||||
'files.button.retry': 'Retry',
|
||||
'files.page.tab.all': 'All Files',
|
||||
'files.page.tab.uploads': 'My Uploads',
|
||||
'files.page.tab.created': 'Created Files',
|
||||
'files.page.tab.shared': 'Shared Files',
|
||||
'files.page.add_file': 'Add File',
|
||||
'files.page.loading': 'Loading files...',
|
||||
'files.page.error': 'Error:',
|
||||
|
||||
// File Table Columns
|
||||
'files.column.name': 'Name',
|
||||
'files.column.filename': 'Filename',
|
||||
'files.column.type': 'Type',
|
||||
'files.column.mimetype': 'MIME Type',
|
||||
'files.column.size': 'Size',
|
||||
'files.column.filesize': 'File Size',
|
||||
'files.column.created': 'Created',
|
||||
'files.column.creationdate': 'Creation Date',
|
||||
'files.column.source': 'Source',
|
||||
|
||||
// File Types
|
||||
'files.type.image': 'Image',
|
||||
'files.type.pdf': 'PDF',
|
||||
'files.type.document': 'Document',
|
||||
'files.type.spreadsheet': 'Spreadsheet',
|
||||
'files.type.text': 'Text',
|
||||
'files.type.video': 'Video',
|
||||
'files.type.audio': 'Audio',
|
||||
'files.type.file': 'File',
|
||||
|
||||
|
||||
|
||||
// File Actions
|
||||
'files.action.preview': 'Preview',
|
||||
'files.action.download': 'Download',
|
||||
'files.action.delete': 'Delete',
|
||||
'files.delete.confirm': 'Are you sure you want to delete the file "{name}"?',
|
||||
|
||||
// File Preview
|
||||
'files.preview.title': 'File Preview',
|
||||
'files.preview.loading': 'Loading preview...',
|
||||
'files.preview.unsupported': 'Preview not available for this file type',
|
||||
'files.preview.error': 'Error loading preview',
|
||||
'files.preview.textInPdfFile': 'Text Preview',
|
||||
'files.preview.pdfFileCorrupted': 'This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible.',
|
||||
|
||||
// Workflows Page
|
||||
'workflows.title': 'Workflows',
|
||||
'workflows.table.title': 'Workflows',
|
||||
'workflows.error.loading': 'Error loading workflows:',
|
||||
'workflows.button.retry': 'Retry',
|
||||
'workflows.table.empty': 'No workflows found',
|
||||
|
||||
// Workflow Table Columns
|
||||
'workflows.column.id': 'ID',
|
||||
'workflows.column.name': 'Name',
|
||||
'workflows.column.status': 'Status',
|
||||
'workflows.column.round': 'Round',
|
||||
'workflows.column.started': 'Started',
|
||||
'workflows.column.lastActivity': 'Last Activity',
|
||||
'workflows.column.messages': 'Messages',
|
||||
|
||||
// Workflow Status
|
||||
'workflows.status.running': 'Running',
|
||||
'workflows.status.completed': 'Completed',
|
||||
'workflows.status.failed': 'Failed',
|
||||
'workflows.status.stopped': 'Stopped',
|
||||
'workflows.status.pending': 'Pending',
|
||||
|
||||
// Workflow Actions
|
||||
'workflows.action.stop': 'Stop',
|
||||
'workflows.action.delete': 'Delete',
|
||||
'workflows.action.stop.tooltip': 'Stop workflow',
|
||||
'workflows.action.delete.tooltip': 'Delete workflow',
|
||||
|
||||
// Workflow Messages
|
||||
'workflows.unnamed': 'Unnamed Workflow',
|
||||
'workflows.delete.confirm': 'Are you sure you want to delete workflow "{name}"?',
|
||||
'workflows.loading': 'Loading workflows...',
|
||||
|
||||
// FormGenerator
|
||||
'formgen.search.placeholder': 'Search...',
|
||||
'formgen.refresh.tooltip': 'Refresh data',
|
||||
'formgen.filter.yes': 'Yes',
|
||||
'formgen.filter.no': 'No',
|
||||
'formgen.filter.clear': 'Clear filter',
|
||||
'formgen.filter.placeholder': 'Filter {column}',
|
||||
'formgen.actions.column': 'Actions',
|
||||
'formgen.pagination.info': 'Page {page} of {total} ({count} items)',
|
||||
'formgen.pagination.pageSize': 'Items per page:',
|
||||
'formgen.pagination.first': 'First page',
|
||||
'formgen.pagination.prev': 'Previous page',
|
||||
'formgen.pagination.next': 'Next page',
|
||||
'formgen.pagination.last': 'Last page',
|
||||
'formgen.select.all': 'Select all items',
|
||||
'formgen.select.item': 'Select this item',
|
||||
'formgen.select.disabled': 'This item cannot be selected',
|
||||
'formgen.delete.multiple': 'Delete ({count})',
|
||||
'formgen.delete.confirm_multiple': 'Are you sure you want to delete the {count} selected items?',
|
||||
|
||||
// Prompts
|
||||
'prompts.title': 'Prompts',
|
||||
'prompts.subtitle': 'Manage your prompts',
|
||||
'prompts.description': 'Create and manage prompts for your AI assistant',
|
||||
'prompts.new_button': 'New Prompt',
|
||||
'prompts.addNew': 'Add Prompt',
|
||||
'prompts.creating': 'Creating...',
|
||||
'prompts.column.name': 'Name',
|
||||
'prompts.column.content': 'Content',
|
||||
'prompts.column.mandateId': 'Mandate ID',
|
||||
'prompts.unnamed': 'Unnamed',
|
||||
'prompts.action.edit': 'Edit',
|
||||
'prompts.action.copy': 'Copy',
|
||||
'prompts.action.delete': 'Delete',
|
||||
'prompts.action.delete.disabled': 'No permission to delete prompt',
|
||||
'prompts.delete.confirm': 'Are you sure you want to delete "{name}"?',
|
||||
'prompts.delete.confirmMultiple': 'Are you sure you want to delete {count} prompts?',
|
||||
'prompts.field.name': 'Prompt Name',
|
||||
'prompts.field.content': 'Prompt Content',
|
||||
'prompts.validation.nameRequired': 'Prompt name cannot be empty',
|
||||
'prompts.validation.nameTooLong': 'Prompt name cannot exceed 100 characters',
|
||||
'prompts.validation.contentRequired': 'Prompt content cannot be empty',
|
||||
'prompts.validation.contentTooLong': 'Prompt content cannot exceed 10,000 characters',
|
||||
'prompts.error.loading': 'Error loading prompts:',
|
||||
'prompts.modal.edit.title': 'Edit Prompt',
|
||||
'prompts.modal.edit.save': 'Save Changes',
|
||||
'prompts.modal.create.title': 'Create New Prompt',
|
||||
'prompts.modal.create.save': 'Create Prompt',
|
||||
'prompts.create.success': 'Prompt created successfully',
|
||||
'prompts.create.error': 'Error creating prompt',
|
||||
|
||||
// Users/Members
|
||||
'users.title': 'Users',
|
||||
'users.column.username': 'Username',
|
||||
'users.column.name': 'Name',
|
||||
'users.column.email': 'Email',
|
||||
'users.column.password': 'Password',
|
||||
'users.column.language': 'Language',
|
||||
'users.column.privilege': 'Privilege',
|
||||
'users.column.enabled': 'Enabled',
|
||||
'users.column.authAuthority': 'Auth Authority',
|
||||
'users.password.placeholder': 'Enter password',
|
||||
'users.noUsername': 'No Username',
|
||||
'users.noName': 'No Name',
|
||||
'users.noEmail': 'No Email',
|
||||
'users.noLanguage': 'No Language',
|
||||
'users.noPrivilege': 'No Privilege',
|
||||
'users.noAuthAuthority': 'No Auth Authority',
|
||||
'users.privilege.viewer': 'Viewer',
|
||||
'users.privilege.user': 'User',
|
||||
'users.privilege.admin': 'Admin',
|
||||
'users.privilege.sysadmin': 'Sysadmin',
|
||||
'users.enabled.yes': 'Yes',
|
||||
'users.enabled.no': 'No',
|
||||
'users.auth.local': 'Local',
|
||||
'users.auth.msft': 'Microsoft',
|
||||
'users.actions.edit': 'Edit',
|
||||
'users.actions.delete': 'Delete',
|
||||
'users.edit.title': 'Edit User',
|
||||
'users.add.title': 'Add User',
|
||||
'users.add.button': 'Add User',
|
||||
'users.add.create': 'Create User',
|
||||
'users.delete.title': 'Delete User',
|
||||
'users.delete.message': 'Are you sure you want to delete this user?',
|
||||
'users.delete.confirm': 'Are you sure you want to delete "{name}"?',
|
||||
'users.delete.warning': 'This action cannot be undone.',
|
||||
'users.action.edit': 'Edit',
|
||||
'users.action.delete': 'Delete',
|
||||
'users.delete.confirmMultiple': 'Are you sure you want to delete {count} users?',
|
||||
'users.error.loading': 'Error loading users:',
|
||||
|
||||
// Team Members
|
||||
'team-members.title': 'Team Members',
|
||||
'team-members.subtitle': 'Manage your team members',
|
||||
'team-members.description': 'Manage team members, set permissions, and configure collaboration settings',
|
||||
'team-members.new_button': 'Add Member',
|
||||
'team-members.action.edit': 'Edit',
|
||||
'team-members.action.delete': 'Delete',
|
||||
'team-members.action.sendPasswordLink': 'Send password setup link',
|
||||
'team-members.action.passwordLinkSent': 'Password link sent!',
|
||||
'team-members.action.passwordLinkFailed': 'Failed to send link',
|
||||
'team-members.field.username': 'Username',
|
||||
'team-members.field.email': 'Email',
|
||||
'team-members.field.password': 'Password',
|
||||
'team-members.field.fullName': 'Full Name',
|
||||
'team-members.field.privilege': 'Privilege',
|
||||
'team-members.modal.create.title': 'Create New Team Member',
|
||||
'team-members.create.success': 'Team member created successfully',
|
||||
'team-members.create.error': 'Error creating team member',
|
||||
|
||||
// SharePoint Test
|
||||
'sharepoint.title': 'SharePoint Test',
|
||||
'sharepoint.table.title': 'SharePoint Documents',
|
||||
'sharepoint.error.loading': 'Error loading SharePoint documents:',
|
||||
'sharepoint.button.retry': 'Retry',
|
||||
'sharepoint.button.testConnection': 'Test Connection',
|
||||
'sharepoint.button.listDocuments': 'List Documents',
|
||||
'sharepoint.button.discoverSites': 'Discover Sites',
|
||||
'sharepoint.column.documentName': 'Document Name',
|
||||
'sharepoint.column.mimeType': 'MIME Type',
|
||||
'sharepoint.column.size': 'Size',
|
||||
'sharepoint.column.path': 'Path',
|
||||
'sharepoint.action.view': 'View',
|
||||
'sharepoint.action.download': 'Download',
|
||||
'sharepoint.connections.title': 'Microsoft Connections',
|
||||
'sharepoint.connections.noConnections': 'No Microsoft connections found. Please create a connection first.',
|
||||
'sharepoint.connections.loading': 'Loading connections...',
|
||||
'sharepoint.sites.discovered': 'Discovered Sites',
|
||||
'sharepoint.sites.noSites': 'No SharePoint sites found',
|
||||
'sharepoint.sites.authError': 'Authentication token expired or invalid. Please reconnect your Microsoft account.',
|
||||
'sharepoint.sites.retryConnection': 'Try reconnecting your Microsoft account in the Connections page.',
|
||||
'sharepoint.form.siteUrl': 'SharePoint Site URL',
|
||||
'sharepoint.form.folderPaths': 'Folder Paths',
|
||||
|
||||
// Speech
|
||||
'speech.title': 'Speech Integration',
|
||||
'speech.subtitle': 'Powered by',
|
||||
'speech.signup.title': 'Speech Integration',
|
||||
'speech.signup.subtitle': 'Powered by',
|
||||
|
||||
'speech.info.va': 'Virtual Assistant (VA)',
|
||||
'speech.info.va_description': 'Give customers a fast and efficient self-service for voice and text queries that\'s available 24/7.',
|
||||
'speech.info.sa': 'Speech Analytics (SA)',
|
||||
'speech.info.sa_description': 'Automatically monitor 100% of conversations to get valuable insights for your business.',
|
||||
'speech.info.vb': 'Voice Biometrics (VB)',
|
||||
'speech.info.vb_description': 'Identify and authenticate callers in seconds with continuous verification and security.',
|
||||
'speech.info.ka': 'Knowledge Agent (KA)',
|
||||
'speech.info.ka_description': 'Unify and deliver info to your customers and staff wherever and whenever they need it.',
|
||||
'speech.info.cp': 'Chat Platform (CP)',
|
||||
'speech.info.cp_description': 'Deliver assistance in live chat and deploy intelligent chatbots in all channels.',
|
||||
'speech.info.aa': 'Agent Assist (AA)',
|
||||
'speech.info.aa_description': 'Put everything your agents need at their fingertips, with a unified agent desktop.',
|
||||
|
||||
'speech.info.about': 'Revolutionary Telephony Integration with Spitch.ai',
|
||||
'speech.info.about_intro': 'Experience the future of client communication through our strategic partnership with Spitch.ai. This groundbreaking integration transforms your PowerOn platform into an intelligent telephony system that seamlessly connects external clients with companies.',
|
||||
'speech.info.workflow_title': 'Seamless Client Workflow:',
|
||||
'speech.info.workflow_description': 'From registration to technical setup - your client registers with PowerOn for telephony services, uploads documents, and automatically receives a technical SIP number from Spitch. Call forwarding can be activated or deactivated at any time, ensuring maximum flexibility and BCM safety.',
|
||||
'speech.info.ai_title': 'AI-Powered Document Generation:',
|
||||
'speech.info.ai_description': 'Our already active document extraction engine automatically generates personalized documents for Spitch based on client-specific data. The AI uses FAQ databases, employee information, and service details to make every call contextual and highly personalized.',
|
||||
'speech.info.sync_title': 'Real-time Data Synchronization:',
|
||||
'speech.info.sync_description': 'Spitch checks client authorization with PowerOn before each call, while all data changes are centrally initiated by PowerOn. Call transcripts are stored in real-time in your PowerOn database with complete client isolation and security. In case of failures, calls are automatically blocked to ensure integrity.',
|
||||
'speech.info.cost_title': 'Cost Savings & Efficiency:',
|
||||
'speech.info.cost_description': 'Clients can switch to the technical SIP number at any time and save significant telephony costs. The integration works like another connector (Outlook, SharePoint) and is seamlessly integrated into your existing workflow.',
|
||||
'speech.info.about_link': 'Learn more',
|
||||
|
||||
'speech.signup.button': 'Connect',
|
||||
'speech.signup.back': 'Back to Speech Integration',
|
||||
'speech.signup.submit': 'Create Mandate',
|
||||
'speech.signup.cancel': 'Cancel',
|
||||
|
||||
'speech.signup.company_info': 'Company Information',
|
||||
'speech.signup.company_name': 'Company Name',
|
||||
'speech.signup.company_name_placeholder': 'Enter your company name',
|
||||
'speech.signup.industry': 'Industry',
|
||||
'speech.signup.industry_placeholder': 'e.g. Financial Services, Technology, etc.',
|
||||
'speech.signup.business_hours': 'Business Hours',
|
||||
'speech.signup.timezone': 'Timezone',
|
||||
|
||||
'speech.signup.contact_info': 'Contact Information',
|
||||
'speech.signup.email': 'Email Address',
|
||||
'speech.signup.email_placeholder': 'contact@company.com',
|
||||
'speech.signup.phone': 'Phone Number',
|
||||
'speech.signup.phone_placeholder': '+41 123 456 789',
|
||||
'speech.signup.street': 'Street',
|
||||
'speech.signup.postal_code': 'Postal Code',
|
||||
'speech.signup.city': 'City',
|
||||
'speech.signup.country': 'Country',
|
||||
|
||||
'speech.signup.contacts_setup': 'Setup Contacts',
|
||||
'speech.signup.contacts_description': 'Would you like to setup contacts for your mandate now? You can also do this later in settings.',
|
||||
'speech.signup.setup_contacts': 'Setup Contacts',
|
||||
'speech.signup.skip_for_now': 'Skip for Now',
|
||||
|
||||
'speech.signup.company_required': 'Company name is required',
|
||||
'speech.signup.industry_required': 'Industry is required',
|
||||
'speech.signup.email_required': 'Email address is required',
|
||||
'speech.signup.email_invalid': 'Please enter a valid email address',
|
||||
'speech.signup.phone_required': 'Phone number is required',
|
||||
'speech.signup.street_required': 'Street is required',
|
||||
'speech.signup.postal_code_required': 'Postal code is required',
|
||||
'speech.signup.city_required': 'City is required',
|
||||
'speech.signup.country_required': 'Country is required',
|
||||
|
||||
'speech.status.submitted': '✓ Mandate Submitted',
|
||||
'speech.status.reset': 'Start Over',
|
||||
|
||||
'speech.confirmation.title': 'Mandate Submitted Successfully!',
|
||||
'speech.confirmation.message': 'Thank you for your interest in our Speech Integration powered by Spitch.ai. We have received your mandate and will review it shortly.',
|
||||
'speech.confirmation.submitted_data': 'Submitted Data:',
|
||||
'speech.confirmation.company': 'Company',
|
||||
'speech.confirmation.industry': 'Industry',
|
||||
'speech.confirmation.email': 'Email',
|
||||
'speech.confirmation.phone': 'Phone',
|
||||
'speech.confirmation.address': 'Address',
|
||||
'speech.confirmation.timezone': 'Timezone',
|
||||
'speech.confirmation.back': 'Back to Speech Integration',
|
||||
'speech.confirmation.reset': 'Start Over',
|
||||
'speech.confirmation.next_steps': 'What happens next?',
|
||||
'speech.confirmation.email_confirmation': 'Email Confirmation',
|
||||
'speech.confirmation.email_confirmation_desc': 'You will receive a confirmation email within the next few minutes.',
|
||||
'speech.confirmation.review_process': 'Review Process',
|
||||
'speech.confirmation.review_process_desc': 'Our team will review your mandate within 1-2 business days.',
|
||||
'speech.confirmation.setup_call': 'Setup Call',
|
||||
'speech.confirmation.setup_call_desc': 'If approved, we\'ll schedule a setup call to configure your integration.',
|
||||
'speech.confirmation.questions': 'Questions?',
|
||||
'speech.confirmation.questions_desc': 'If you have any questions about your mandate or the integration process, please don\'t hesitate to contact our support team.',
|
||||
'speech.confirmation.transcript_management': 'Transcript Management',
|
||||
'speech.confirmation.speech_settings': 'Speech Settings',
|
||||
|
||||
'speech.transcripts.title': 'Transcript Management',
|
||||
'speech.transcripts.new_transcript': 'New Transcript',
|
||||
'speech.transcripts.recent_transcripts': 'Recent Transcripts',
|
||||
'speech.transcripts.no_transcripts': 'No transcripts available',
|
||||
'speech.transcripts.date': 'Date',
|
||||
'speech.transcripts.duration': 'Duration',
|
||||
'speech.transcripts.status': 'Status',
|
||||
'speech.transcripts.transcript': 'Transcript',
|
||||
'speech.transcripts.processing': 'Processing transcript...',
|
||||
'speech.transcripts.status.completed': 'Completed',
|
||||
'speech.transcripts.status.processing': 'Processing',
|
||||
'speech.transcripts.status.failed': 'Failed',
|
||||
'speech.transcripts.access_denied_title': 'Access Denied',
|
||||
'speech.transcripts.access_denied_message': 'You must first sign up for speech integration to access transcript management.',
|
||||
'speech.transcripts.sign_up_now': 'Sign Up Now',
|
||||
'speech.transcripts.subject': 'Subject',
|
||||
'speech.transcripts.start_time': 'Start Time',
|
||||
'speech.transcripts.end_time': 'End Time',
|
||||
'speech.transcripts.caller': 'Caller',
|
||||
'speech.transcripts.recipient': 'Recipient',
|
||||
'speech.transcripts.tags': 'Tags',
|
||||
'speech.transcripts.created': 'Created',
|
||||
'speech.transcripts.view': 'View',
|
||||
'speech.transcripts.download': 'Download',
|
||||
|
||||
'speech.settings.title': 'Speech Integration Settings',
|
||||
'speech.settings.description': 'Manage your speech integration configuration and preferences.',
|
||||
'speech.settings.company_info': 'Company Information',
|
||||
'speech.settings.contact_info': 'Contact Information',
|
||||
'speech.settings.business_hours': 'Business Hours & Timezone',
|
||||
'speech.settings.save': 'Save Changes',
|
||||
'speech.settings.saving': 'Saving...',
|
||||
'speech.settings.save_success': 'Settings saved successfully!',
|
||||
'speech.settings.save_error': 'Failed to save settings. Please try again.',
|
||||
'speech.settings.reset': 'Reset to Default',
|
||||
'speech.settings.reset_confirm': 'Are you sure you want to reset all speech integration settings? This action cannot be undone.',
|
||||
'speech.settings.reset_success': 'Settings have been reset successfully.',
|
||||
'speech.settings.no_data': 'No speech integration data found. Please sign up first to access settings.',
|
||||
'speech.settings.sign_up_now': 'Sign Up Now',
|
||||
|
||||
// Message Overlay Types
|
||||
'message.success.title': 'Success',
|
||||
'message.success.upload': 'File uploaded successfully!',
|
||||
'message.info.title': 'Information',
|
||||
'message.info.processing': 'Processing your request...',
|
||||
'message.error.title': 'Error',
|
||||
'message.error.upload_failed': 'Upload failed. Please try again.',
|
||||
|
||||
// Warning Messages
|
||||
'warning.duplicate_file.title': 'File Already Exists',
|
||||
'warning.duplicate_file.message': 'The file "{fileName}" already exists with identical content. The existing file will be reused.',
|
||||
|
||||
// Automations Page
|
||||
'automations.title': 'Automations',
|
||||
'automations.description': 'Manage workflow automations',
|
||||
'automations.subtitle': 'Scheduled and automated workflows',
|
||||
'automations.new_button': 'New Automation',
|
||||
'automations.action.execute': 'Execute',
|
||||
'automations.action.edit': 'Edit',
|
||||
'automations.action.delete': 'Delete',
|
||||
'automations.modal.create.title': 'Create New Automation',
|
||||
'automations.create.success': 'Automation created successfully',
|
||||
'automations.create.error': 'Error creating automation',
|
||||
|
||||
// Basedata Group (formerly Administration)
|
||||
'basedata.title': 'Base Data',
|
||||
'basedata.description': 'Basic data and resources',
|
||||
|
||||
// Administration (legacy, kept for compatibility)
|
||||
'administration.title': 'Utils',
|
||||
'administration.description': 'Utilities and tools',
|
||||
'administration.subtitle': 'Administration and management tools',
|
||||
'administration.intro.description': 'This section contains all administration and management tools for your workspace.',
|
||||
'administration.features.title': 'Available Tools',
|
||||
'administration.features.description': 'Management tools include:',
|
||||
'administration.features.file_management': 'File Management - Upload and organize documents',
|
||||
'administration.features.user_management': 'User Management - Manage team members and permissions',
|
||||
'administration.features.system_settings': 'System Settings - Configure workspace settings',
|
||||
'administration.features.data_management': 'Data Management - Handle data imports and exports',
|
||||
|
||||
// Admin pages
|
||||
'admin.mandates.title': 'Mandates',
|
||||
'admin.mandates.subtitle': 'Manage mandates and permissions',
|
||||
'admin.mandates.description': 'Mandate management',
|
||||
'admin.mandates.description_text': 'Manage mandates and their associated permissions.',
|
||||
'admin.mandates.new_button': 'Add Mandate',
|
||||
'admin.mandates.action.edit': 'Edit',
|
||||
'admin.mandates.action.delete': 'Delete',
|
||||
'admin.mandates.modal.create.title': 'Create New Mandate',
|
||||
'admin.mandates.create.success': 'Mandate created successfully',
|
||||
'admin.mandates.create.error': 'Error creating mandate',
|
||||
|
||||
'admin.rbac-rules.title': 'RBAC Rules',
|
||||
'admin.rbac-rules.subtitle': 'Role-Based Access Control rules',
|
||||
'admin.rbac-rules.description': 'RBAC rules management',
|
||||
'admin.rbac-rules.description_text': 'Configure and manage Role-Based Access Control rules.',
|
||||
'admin.rbac-rules.new_button': 'Add RBAC Rule',
|
||||
'admin.rbac-rules.action.edit': 'Edit',
|
||||
'admin.rbac-rules.action.delete': 'Delete',
|
||||
'admin.rbac-rules.modal.create.title': 'Create New RBAC Rule',
|
||||
'admin.rbac-rules.create.success': 'RBAC rule created successfully',
|
||||
'admin.rbac-rules.create.error': 'Error creating RBAC rule',
|
||||
|
||||
'admin.rbac-role.title': 'RBAC Roles',
|
||||
'admin.rbac-role.subtitle': 'Role management',
|
||||
'admin.rbac-role.description': 'RBAC role management',
|
||||
'admin.rbac-role.description_text': 'Create and manage RBAC roles and their permissions.',
|
||||
'admin.rbac-role.new_button': 'Add Role',
|
||||
'admin.rbac-role.action.edit': 'Edit',
|
||||
'admin.rbac-role.action.delete': 'Delete',
|
||||
'admin.rbac-role.modal.create.title': 'Create New Role',
|
||||
'admin.rbac-role.create.success': 'Role created successfully',
|
||||
'admin.rbac-role.create.error': 'Error creating role',
|
||||
|
||||
'admin.admin-settings.title': 'Admin Settings',
|
||||
'admin.admin-settings.subtitle': 'Administrative settings',
|
||||
'admin.admin-settings.description': 'Administrative settings',
|
||||
'admin.admin-settings.description_text': 'Configure administrative settings and system preferences.',
|
||||
|
||||
// Start page
|
||||
'start.title': 'Start',
|
||||
'start.description': 'Welcome to your workspace',
|
||||
'start.subtitle': 'Welcome to your workspace',
|
||||
'start.intro.description': 'This is your starting point for accessing all workspace features and tools.',
|
||||
'start.features.title': 'Quick Access',
|
||||
'start.features.description': 'Get started with:',
|
||||
'start.features.quick_access': 'Quick Access - Jump to frequently used features',
|
||||
'start.features.recent_activities': 'Recent Activities - View your latest work',
|
||||
'start.features.overview': 'Overview - See workspace status and updates',
|
||||
'start.features.navigation': 'Navigation - Explore all available tools',
|
||||
|
||||
// Projects page
|
||||
'projects.title': 'Projects',
|
||||
'projects.subtitle': 'Project Management',
|
||||
'projects.description': 'Project management and organization',
|
||||
'projects.description_text': 'Search for locations by address or coordinates, or use natural language to create and manage projects.',
|
||||
'projects.command.placeholder': 'Enter a command (e.g., "Create a new project named \'Main Street 42\'")',
|
||||
'projects.command.empty': 'No commands executed yet. Send a command to see results here.',
|
||||
|
||||
// Data Management page
|
||||
'data-management.title': 'Data Management',
|
||||
'data-management.subtitle': 'Data Management',
|
||||
'data-management.description': 'Data management with tables',
|
||||
'data-management.description_text': 'Manage data through tables. Select a table or use natural language to execute commands.',
|
||||
'data-management.command.placeholder': 'Enter a command (e.g., "Create a new project named \'Main Street 42\'")',
|
||||
'data-management.command.empty': 'No commands executed yet. Send a command to see results here.',
|
||||
|
||||
// Drag and Drop
|
||||
'dragdrop.overlay.default_text': 'Drop files here',
|
||||
'dragdrop.overlay.default_subtext': 'You can also click the upload button',
|
||||
'dragdrop.overlay.processing': 'Processing files...',
|
||||
'dragdrop.overlay.error': 'Error processing files',
|
||||
|
||||
// Trustee Feature
|
||||
'trustee.title': 'Trustee',
|
||||
'trustee.subtitle': 'Trustee Management',
|
||||
'trustee.description': 'Manage trustee organisations, contracts, and bookings',
|
||||
|
||||
// Trustee Organisations
|
||||
'trustee.organisations.title': 'Organisations',
|
||||
'trustee.organisations.subtitle': 'Manage trustee organisations',
|
||||
'trustee.organisations.description': 'Management of trustee organisations',
|
||||
'trustee.organisations.new_button': 'New Organisation',
|
||||
'trustee.organisations.field.id': 'ID',
|
||||
'trustee.organisations.field.id_placeholder': 'e.g. trustee-ag-zurich',
|
||||
'trustee.organisations.field.label': 'Label',
|
||||
'trustee.organisations.field.label_placeholder': 'e.g. Trustee AG Zurich',
|
||||
'trustee.organisations.field.enabled': 'Enabled',
|
||||
'trustee.organisations.modal.create.title': 'Create New Organisation',
|
||||
'trustee.organisations.create.success': 'Organisation created successfully',
|
||||
'trustee.organisations.create.error': 'Error creating organisation',
|
||||
'trustee.organisations.action.edit': 'Edit',
|
||||
'trustee.organisations.action.delete': 'Delete',
|
||||
|
||||
// Trustee Roles
|
||||
'trustee.roles.title': 'Roles',
|
||||
'trustee.roles.subtitle': 'Manage trustee roles',
|
||||
'trustee.roles.description': 'Management of feature-specific roles',
|
||||
'trustee.roles.new_button': 'New Role',
|
||||
'trustee.roles.field.id': 'Role ID',
|
||||
'trustee.roles.field.id_placeholder': 'e.g. admin, operate, userreport',
|
||||
'trustee.roles.field.desc': 'Description',
|
||||
'trustee.roles.field.desc_placeholder': 'Role description',
|
||||
'trustee.roles.modal.create.title': 'Create New Role',
|
||||
'trustee.roles.create.success': 'Role created successfully',
|
||||
'trustee.roles.create.error': 'Error creating role',
|
||||
'trustee.roles.action.edit': 'Edit',
|
||||
'trustee.roles.action.delete': 'Delete',
|
||||
|
||||
// Trustee Access
|
||||
'trustee.access.title': 'Access',
|
||||
'trustee.access.subtitle': 'Manage user access',
|
||||
'trustee.access.description': 'Management of user access to organisations',
|
||||
'trustee.access.new_button': 'New Access',
|
||||
'trustee.access.field.organisationId': 'Organisation',
|
||||
'trustee.access.field.roleId': 'Role',
|
||||
'trustee.access.field.userId': 'User',
|
||||
'trustee.access.field.contractId': 'Contract (optional)',
|
||||
'trustee.access.field.contractId_placeholder': 'Empty = Access to all contracts',
|
||||
'trustee.access.modal.create.title': 'Create New Access',
|
||||
'trustee.access.create.success': 'Access created successfully',
|
||||
'trustee.access.create.error': 'Error creating access',
|
||||
'trustee.access.action.edit': 'Edit',
|
||||
'trustee.access.action.delete': 'Delete',
|
||||
|
||||
// Trustee Contracts
|
||||
'trustee.contracts.title': 'Contracts',
|
||||
'trustee.contracts.subtitle': 'Manage customer contracts',
|
||||
'trustee.contracts.description': 'Management of customer contracts',
|
||||
'trustee.contracts.new_button': 'New Contract',
|
||||
'trustee.contracts.field.organisationId': 'Organisation',
|
||||
'trustee.contracts.field.label': 'Label',
|
||||
'trustee.contracts.field.label_placeholder': 'e.g. Muster AG 2026',
|
||||
'trustee.contracts.field.enabled': 'Enabled',
|
||||
'trustee.contracts.modal.create.title': 'Create New Contract',
|
||||
'trustee.contracts.create.success': 'Contract created successfully',
|
||||
'trustee.contracts.create.error': 'Error creating contract',
|
||||
'trustee.contracts.action.edit': 'Edit',
|
||||
'trustee.contracts.action.delete': 'Delete',
|
||||
|
||||
// Trustee Documents
|
||||
'trustee.documents.title': 'Documents',
|
||||
'trustee.documents.subtitle': 'Manage receipts',
|
||||
'trustee.documents.description': 'Management of documents and receipts',
|
||||
'trustee.documents.new_button': 'New Document',
|
||||
'trustee.documents.field.organisationId': 'Organisation',
|
||||
'trustee.documents.field.contractId': 'Contract',
|
||||
'trustee.documents.field.documentName': 'File Name',
|
||||
'trustee.documents.field.documentName_placeholder': 'e.g. Receipt.pdf',
|
||||
'trustee.documents.field.documentMimeType': 'File Type',
|
||||
'trustee.documents.modal.create.title': 'Create New Document',
|
||||
'trustee.documents.create.success': 'Document created successfully',
|
||||
'trustee.documents.create.error': 'Error creating document',
|
||||
'trustee.documents.action.edit': 'Edit',
|
||||
'trustee.documents.action.delete': 'Delete',
|
||||
'trustee.documents.action.download': 'Download',
|
||||
|
||||
// Trustee Positions
|
||||
'trustee.positions.title': 'Positions',
|
||||
'trustee.positions.subtitle': 'Manage booking positions',
|
||||
'trustee.positions.description': 'Management of booking positions (expense entries)',
|
||||
'trustee.positions.new_button': 'New Position',
|
||||
'trustee.positions.field.organisationId': 'Organisation',
|
||||
'trustee.positions.field.contractId': 'Contract',
|
||||
'trustee.positions.field.valuta': 'Value Date',
|
||||
'trustee.positions.field.company': 'Company',
|
||||
'trustee.positions.field.company_placeholder': 'Company name',
|
||||
'trustee.positions.field.desc': 'Description',
|
||||
'trustee.positions.field.bookingCurrency': 'Booking Currency',
|
||||
'trustee.positions.field.bookingAmount': 'Booking Amount',
|
||||
'trustee.positions.field.originalCurrency': 'Original Currency',
|
||||
'trustee.positions.field.originalAmount': 'Original Amount',
|
||||
'trustee.positions.field.vatPercentage': 'VAT %',
|
||||
'trustee.positions.field.vatAmount': 'VAT Amount',
|
||||
'trustee.positions.modal.create.title': 'Create New Position',
|
||||
'trustee.positions.create.success': 'Position created successfully',
|
||||
'trustee.positions.create.error': 'Error creating position',
|
||||
'trustee.positions.action.edit': 'Edit',
|
||||
'trustee.positions.action.delete': 'Delete',
|
||||
};
|
||||
|
|
@ -1,928 +0,0 @@
|
|||
export default {
|
||||
// Navigation
|
||||
'nav.dashboard': 'Centre d\'activité',
|
||||
'nav.files': 'Fichiers',
|
||||
'nav.team': 'Espace équipe',
|
||||
'nav.workflows': 'Workflows',
|
||||
'nav.connections': 'Connections',
|
||||
'nav.settings': 'Paramètres',
|
||||
'nav.testSharepoint': 'Test SharePoint',
|
||||
'nav.speech': 'Parole',
|
||||
'nav.transcript_management': 'Gestion des Transcriptions',
|
||||
|
||||
// Settings page
|
||||
'settings.title': 'Paramètres',
|
||||
'settings.appearance': 'Apparence',
|
||||
'settings.language': 'Langue',
|
||||
'settings.about': 'À propos',
|
||||
'settings.version': 'Version',
|
||||
'settings.theme': 'Thème',
|
||||
'settings.theme.description': 'Basculer entre le mode clair et sombre',
|
||||
'settings.language.description': 'Choisissez votre langue préférée',
|
||||
'settings.theme.light': 'Clair',
|
||||
'settings.theme.dark': 'Sombre',
|
||||
'settings.theme.toggle.light': 'Passer en mode clair',
|
||||
'settings.theme.toggle.dark': 'Passer en mode sombre',
|
||||
'settings.userinfo': 'Informations utilisateur',
|
||||
'settings.userinfo.description': 'Gérez vos informations de compte',
|
||||
'settings.userinfo.username': 'Nom d\'utilisateur',
|
||||
'settings.userinfo.fullname': 'Nom complet',
|
||||
'settings.userinfo.email': 'Adresse e-mail',
|
||||
'settings.userinfo.phone_name': 'Nom au téléphone',
|
||||
'settings.userinfo.phone_name.description': 'Comment souhaitez-vous être appelé au téléphone ?',
|
||||
'settings.userinfo.language': 'Langue',
|
||||
'settings.userinfo.privilege': 'Niveau de privilège',
|
||||
'settings.userinfo.enabled': 'Statut du compte',
|
||||
'settings.userinfo.auth_authority': 'Fournisseur d\'authentification',
|
||||
'settings.userinfo.enabled.true': 'Actif',
|
||||
'settings.userinfo.enabled.false': 'Inactif',
|
||||
'settings.userinfo.loading': 'Chargement des informations utilisateur...',
|
||||
'settings.userinfo.error': 'Erreur lors du chargement des informations utilisateur',
|
||||
'settings.userinfo.save': 'Enregistrer les modifications',
|
||||
'settings.userinfo.saving': 'Enregistrement...',
|
||||
'settings.userinfo.success': 'Informations utilisateur mises à jour avec succès',
|
||||
'settings.userinfo.update_error': 'Erreur lors de la mise à jour des informations utilisateur',
|
||||
'settings.userinfo.managed_by': 'Géré par {provider}',
|
||||
'settings.userinfo.managed_note': 'Ce champ est géré par {provider} et ne peut pas être modifié',
|
||||
|
||||
// Languages
|
||||
'language.german': 'Deutsch',
|
||||
'language.english': 'English',
|
||||
'language.french': 'Français',
|
||||
|
||||
// Common
|
||||
'common.loading': 'Chargement...',
|
||||
'common.error': 'Erreur',
|
||||
'common.success': 'Succès',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
'common.close': 'Fermer',
|
||||
'common.retry': 'Réessayer',
|
||||
'common.create': 'Créer',
|
||||
'common.creating': 'Création...',
|
||||
|
||||
// Auth
|
||||
'auth.login': 'Se connecter',
|
||||
'auth.register': 'S\'inscrire',
|
||||
'auth.logout': 'Se déconnecter',
|
||||
'auth.email': 'E-mail',
|
||||
'auth.password': 'Mot de passe',
|
||||
|
||||
// Dashboard
|
||||
'dashboard.prompt.template': 'Modèle de prompt',
|
||||
'dashboard.prompt.settings': 'Paramètres',
|
||||
'dashboard.chat.area': 'Zone de chat',
|
||||
'dashboard.chat.history': 'Historique des workflows',
|
||||
'dashboard.log.title': 'Journal',
|
||||
'dashboard.log.workflow': 'Workflow',
|
||||
'dashboard.log.no_workflow': 'Aucun workflow sélectionné',
|
||||
'dashboard.log.loading': 'Chargement des logs...',
|
||||
'dashboard.log.error': 'Erreur lors du chargement des logs',
|
||||
'dashboard.log.no_logs': 'Aucun log disponible pour ce workflow',
|
||||
'dashboard.log.waiting': 'Workflow en cours... En attente des logs...',
|
||||
'dashboard.log.fetch_failed': 'Échec du chargement des logs',
|
||||
'dashboard.log.level.info': 'INFO',
|
||||
'dashboard.workflow_dropdown.loading': 'Chargement...',
|
||||
'dashboard.workflow_dropdown.error': 'Erreur',
|
||||
'dashboard.workflow_dropdown.select_workflow': 'Sélectionner un workflow',
|
||||
'dashboard.workflow_dropdown.available_workflows': 'Workflows disponibles',
|
||||
'dashboard.workflow_dropdown.no_workflows': 'Aucun workflow disponible',
|
||||
|
||||
// Workflow Stats
|
||||
'dashboard.stats.workflow': 'Workflow',
|
||||
'dashboard.stats.status': 'Statut',
|
||||
'dashboard.stats.rounds': 'Tours',
|
||||
'dashboard.stats.messages': 'Messages',
|
||||
'dashboard.stats.files': 'Fichiers',
|
||||
'dashboard.stats.tokens': 'Jetons',
|
||||
'dashboard.stats.data_sent': 'Données envoyées',
|
||||
'dashboard.stats.data_received': 'Données reçues',
|
||||
'dashboard.stats.success_rate': 'Taux de succès',
|
||||
'dashboard.stats.errors': 'Erreurs',
|
||||
'dashboard.stats.started': 'Démarré',
|
||||
|
||||
// Prompt Set
|
||||
'promptset.loading': 'Chargement des prompts...',
|
||||
'promptset.error.loading': 'Erreur lors du chargement des prompts',
|
||||
'promptset.retry': 'Réessayer',
|
||||
'promptset.new_prompt': 'Nouveau prompt',
|
||||
'promptset.prompt_count': 'Prompt',
|
||||
'promptset.prompt_count_plural': 'Prompts',
|
||||
'promptset.no_prompts': 'Aucun prompt disponible',
|
||||
'promptset.created': 'Créé',
|
||||
'promptset.run_tooltip': 'Exécuter le prompt',
|
||||
'promptset.share_tooltip': 'Partager le prompt',
|
||||
'promptset.delete_tooltip': 'Supprimer le prompt',
|
||||
'promptset.confirm_delete': 'Cliquez à nouveau pour confirmer',
|
||||
'promptset.deleting': 'Suppression...',
|
||||
'promptset.confirm_click': 'Cliquez pour confirmer',
|
||||
'promptset.delete_error': 'Erreur lors de la suppression',
|
||||
'promptset.deleting_message': 'Suppression du prompt...',
|
||||
|
||||
// Connections
|
||||
'connections.title': 'Connexions',
|
||||
'connections.subtitle': 'Gérez vos connexions de service',
|
||||
'connections.connect_google': 'Connecter Google',
|
||||
'connections.connect_microsoft': 'Connecter Microsoft',
|
||||
'connections.add_google_button': 'Ajouter une connexion Google',
|
||||
'connections.add_microsoft_button': 'Ajouter une connexion Microsoft',
|
||||
'connections.create_google_title': 'Créer une connexion Google',
|
||||
'connections.create_microsoft_title': 'Créer une connexion Microsoft',
|
||||
'connections.edit_connection_title': 'Modifier la connexion {authority}',
|
||||
'connections.update_connection': 'Mettre à jour la connexion',
|
||||
'connections.service_connections': 'Connexions de service',
|
||||
'connections.error': 'Erreur',
|
||||
'connections.connection_error': 'Erreur de connexion',
|
||||
'connections.disconnect_error': 'Erreur de déconnexion',
|
||||
'connections.unknown': 'Inconnu',
|
||||
'connections.not_available': 'N/D',
|
||||
'connections.invalid_date': 'Date invalide',
|
||||
'connections.confirm_delete': 'Êtes-vous sûr de vouloir supprimer la connexion {service} ?',
|
||||
'connections.confirm_delete_multiple': 'Êtes-vous sûr de vouloir supprimer {count} connexions ?',
|
||||
|
||||
// Connection Fields
|
||||
'connections.field.service': 'Service',
|
||||
'connections.field.status': 'Statut',
|
||||
'connections.field.external_username': 'Nom d\'utilisateur externe',
|
||||
'connections.field.external_email': 'E-mail externe',
|
||||
'connections.field.connected_at': 'Connecté le',
|
||||
'connections.field.last_checked': 'Dernière vérification',
|
||||
'connections.field.expires_at': 'Expire le',
|
||||
|
||||
// Connection Columns
|
||||
'connections.column.username': 'Nom d\'utilisateur',
|
||||
'connections.column.email': 'E-mail',
|
||||
'connections.column.authority': 'Service',
|
||||
'connections.column.status': 'Statut',
|
||||
'connections.column.connectedat': 'Connecté le',
|
||||
'connections.column.lastchecked': 'Dernière vérification',
|
||||
'connections.column.expiresat': 'Expire le',
|
||||
|
||||
// Connection Services
|
||||
'connections.service.google': 'Google',
|
||||
'connections.service.microsoft': 'Microsoft',
|
||||
'connections.service.local': 'Local',
|
||||
|
||||
// Connection Placeholders
|
||||
'connections.placeholder.external_username': 'Entrez le nom d\'utilisateur externe',
|
||||
'connections.placeholder.external_email': 'Entrez l\'adresse e-mail externe',
|
||||
|
||||
// Connection Actions
|
||||
'connections.action.edit': 'Modifier',
|
||||
'connections.action.update': 'Mettre à jour',
|
||||
'connections.action.delete': 'Supprimer',
|
||||
'connections.action.connect': 'Connecter',
|
||||
'connections.action.refresh': 'Actualiser',
|
||||
|
||||
// Prompt Modal
|
||||
'modal.create_prompt': 'Créer un nouveau prompt',
|
||||
'modal.name_required': 'Le nom est requis',
|
||||
'modal.content_required': 'Le contenu est requis',
|
||||
'modal.create_error': 'Erreur lors de la création du prompt',
|
||||
'modal.name_label': 'Nom',
|
||||
'modal.content_label': 'Contenu',
|
||||
'modal.name_placeholder': 'Entrez un nom pour le prompt',
|
||||
'modal.content_placeholder': 'Entrez le contenu du prompt',
|
||||
'modal.cancel': 'Annuler',
|
||||
'modal.creating': 'Création...',
|
||||
'modal.create': 'Créer le prompt',
|
||||
|
||||
// Share Modal
|
||||
'share_modal.title': 'Partager le prompt',
|
||||
'share_modal.select_users': 'Sélectionner les utilisateurs',
|
||||
'share_modal.select_all': 'Tout sélectionner',
|
||||
'share_modal.deselect_all': 'Tout désélectionner',
|
||||
'share_modal.loading_users': 'Chargement des utilisateurs...',
|
||||
'share_modal.error_loading_users': 'Erreur lors du chargement des utilisateurs',
|
||||
'share_modal.no_users_available': 'Aucun utilisateur disponible',
|
||||
'share_modal.no_users_selected': 'Veuillez sélectionner au moins un utilisateur',
|
||||
'share_modal.one_user_selected': '1 utilisateur sélectionné',
|
||||
'share_modal.multiple_users_selected': '{count} utilisateurs sélectionnés',
|
||||
'share_modal.custom_title': 'Titre personnalisé (facultatif)',
|
||||
'share_modal.title_placeholder': 'Entrez un titre personnalisé',
|
||||
'share_modal.message': 'Message (facultatif)',
|
||||
'share_modal.message_placeholder': 'Ajoutez un message pour les destinataires',
|
||||
'share_modal.share': 'Partager',
|
||||
'share_modal.sharing': 'Partage en cours...',
|
||||
'share_modal.share_error': 'Erreur lors du partage du prompt',
|
||||
|
||||
// Prompt Settings
|
||||
'prompt_settings.title': 'Paramètres de prompt',
|
||||
'prompt_settings.content_placeholder': 'Le contenu des paramètres sera ajouté dans les futures mises à jour.',
|
||||
|
||||
// Chat Area
|
||||
'chat.continue_conversation': 'Continuer la conversation...',
|
||||
'chat.enter_message': 'Entrez votre message...',
|
||||
'chat.remove_file': 'Supprimer le fichier',
|
||||
'chat.attach_file': 'Joindre un fichier',
|
||||
'chat.you': 'Vous',
|
||||
'chat.click_to_open': 'Cliquez pour ouvrir',
|
||||
'chat.preview_document': 'Aperçu du document',
|
||||
'chat.download_document': 'Télécharger le document',
|
||||
'chat.workflow_failed': 'Échec du workflow.',
|
||||
'chat.retry_workflow': 'Réessayer',
|
||||
'chat.sending_followup': 'Envoi du message de suivi...',
|
||||
'chat.sending_message': 'Envoi du message...',
|
||||
'chat.error_prefix': 'Erreur:',
|
||||
'chat.error_loading_messages': 'Erreur lors du chargement des messages:',
|
||||
'chat.loading_workflow_messages': 'Chargement des messages de workflow...',
|
||||
'chat.start_conversation': 'Commencez une conversation en entrant un message, en sélectionnant un modèle ou en continuant un workflow précédent...',
|
||||
|
||||
// Chat Input Area
|
||||
'chat.input.continue_workflow': 'Continuer la conversation...',
|
||||
'chat.input.enter_message': 'Ou entrez votre message...',
|
||||
'chat.input.continuing_workflow': 'Workflow en cours',
|
||||
'chat.input.workflow': 'Workflow',
|
||||
'chat.input.files_attached': 'fichier',
|
||||
'chat.input.files_attached_plural': 'fichiers',
|
||||
'chat.input.files_attached_label': 'attaché',
|
||||
'chat.input.error_prefix': 'Erreur:',
|
||||
'chat.input.attach_files': 'Joindre des fichiers',
|
||||
'chat.input.sending': 'Envoi...',
|
||||
'chat.input.processing': 'Traitement...',
|
||||
'chat.input.continue': 'Continuer',
|
||||
'chat.input.send': 'Envoyer',
|
||||
'chat.input.stop': 'Arrêter',
|
||||
'chat.input.stopping': 'Arrêt...',
|
||||
'chat.input.drop_files_here': 'Déposez les fichiers ici pour les joindre',
|
||||
'chat.input.drop_disabled': 'Dépôt de fichiers désactivé pendant le workflow',
|
||||
'chat.input.new_chat': 'Nouveau Chat',
|
||||
'chat.input.using_prompt': 'Utilisation du modèle:',
|
||||
'chat.input.select_prompt': 'Sélectionner un prompt...',
|
||||
'chat.input.loading_prompts': 'Chargement des prompts...',
|
||||
'chat.input.clear_prompt': 'Effacer le prompt',
|
||||
|
||||
// File Preview
|
||||
'file_preview.loading': 'Chargement de l\'aperçu...',
|
||||
'file_preview.error': 'Erreur',
|
||||
'file_preview.no_preview': 'Aucun aperçu disponible',
|
||||
'file_preview.close_preview': 'Fermer l\'aperçu',
|
||||
'file_preview.python': 'Python',
|
||||
|
||||
// Chat History
|
||||
'chat_history.loading': 'Chargement des workflows...',
|
||||
'chat_history.error_loading': 'Erreur lors du chargement des workflows:',
|
||||
'chat_history.try_again': 'Réessayer',
|
||||
'chat_history.title': 'Historique des workflows',
|
||||
'chat_history.workflow_count': 'Workflow',
|
||||
'chat_history.workflow_count_plural': 'Workflows',
|
||||
'chat_history.empty_state': 'Aucun workflow disponible',
|
||||
'chat_history.confirm_delete': 'Êtes-vous sûr de vouloir supprimer le workflow "{id}..."?',
|
||||
'chat_history.no_message_content': 'Aucun contenu de message disponible',
|
||||
'chat_history.unknown_date': 'Date inconnue',
|
||||
'chat_history.invalid_date': 'Date invalide',
|
||||
'chat_history.started': 'Démarré:',
|
||||
'chat_history.last_activity': 'Dernière activité:',
|
||||
'chat_history.round': 'Tour',
|
||||
'chat_history.resume_tooltip': 'Reprendre le workflow',
|
||||
'chat_history.delete_tooltip': 'Supprimer le workflow',
|
||||
'chat_history.deleting': 'Suppression du workflow...',
|
||||
|
||||
// Chat Messages
|
||||
'chat.messages.no_workflow_selected': 'Aucun workflow sélectionné',
|
||||
'chat.messages.no_workflow_selected_description': 'Sélectionnez un workflow dans la liste ou démarrez un nouveau workflow',
|
||||
'chat.messages.loading_progress': 'Chargement du progrès...',
|
||||
'chat.messages.tasks': 'Tâches',
|
||||
'chat.messages.workflow_progress': 'Progression du workflow',
|
||||
'chat.messages.analyzing_workflow': 'Analyse du workflow...',
|
||||
'chat.messages.scroll_to_bottom_btn': 'Faire défiler vers le bas',
|
||||
|
||||
// Workflow Status
|
||||
'status.error': 'ERREUR',
|
||||
'status.failed': 'ÉCHEC',
|
||||
'status.stopped': 'ARRÊTÉ',
|
||||
'status.cancelled': 'ANNULÉ',
|
||||
'status.running': 'EN COURS',
|
||||
'status.processing': 'TRAITEMENT',
|
||||
'status.completed': 'TERMINÉ',
|
||||
'status.pending': 'EN ATTENTE',
|
||||
|
||||
// Files
|
||||
'files.unknown_size': 'Taille inconnue',
|
||||
'files.unknown_date': 'Date inconnue',
|
||||
'files.source.uploaded': 'Téléchargé',
|
||||
'files.source.ai_created': 'Créé par IA',
|
||||
'files.source.shared': 'Partagé',
|
||||
'files.source.unknown': 'Inconnu',
|
||||
'files.preview_tooltip': 'Aperçu du fichier',
|
||||
'files.download_tooltip': 'Télécharger le fichier',
|
||||
'files.delete_tooltip': 'Supprimer le fichier',
|
||||
'files.delete_confirm_tooltip': 'Cliquez à nouveau pour confirmer la suppression',
|
||||
'files.downloading': 'Téléchargement...',
|
||||
'files.deleting': 'Suppression...',
|
||||
'files.delete_confirm': 'Cliquez pour confirmer...',
|
||||
'files.no_files': 'Aucun fichier trouvé.',
|
||||
'files.no_shared_files': 'Aucun fichier partagé trouvé.',
|
||||
'files.no_ai_files': 'Aucun fichier créé par IA trouvé.',
|
||||
'files.no_uploaded_files': 'Aucun fichier téléchargé trouvé.',
|
||||
'files.header.name': 'Nom',
|
||||
'files.header.type': 'Type',
|
||||
'files.header.size': 'Taille',
|
||||
'files.header.date': 'Date',
|
||||
'files.selector.title': 'Sélectionner des fichiers',
|
||||
'files.selector.tab.all': 'Tous les fichiers',
|
||||
'files.selector.tab.uploads': 'Téléchargés',
|
||||
'files.selector.tab.created': 'Créés par IA',
|
||||
'files.selector.tab.shared': 'Partagés',
|
||||
'files.selector.select_all': 'Tout sélectionner',
|
||||
'files.selector.deselect_all': 'Tout désélectionner',
|
||||
'files.selector.file_selected': 'Fichier',
|
||||
'files.selector.files_selected': 'Fichiers',
|
||||
'files.selector.selected_suffix': 'sélectionné(s)',
|
||||
'files.selector.upload_new': 'Télécharger un nouveau fichier',
|
||||
'files.selector.loading': 'Chargement des fichiers...',
|
||||
'files.selector.error_loading': 'Erreur lors du chargement des fichiers:',
|
||||
'files.upload.title': 'Télécharger un fichier',
|
||||
'files.upload.drop_here': 'Déposer le fichier ici...',
|
||||
'files.upload.uploading': 'Téléchargement...',
|
||||
'files.upload.drag_files': 'Glisser les fichiers ici',
|
||||
'files.upload.or': 'ou',
|
||||
'files.upload.browse': 'Parcourir',
|
||||
'files.upload.selected_file': 'Fichier sélectionné:',
|
||||
'files.upload.upload_button': 'Télécharger',
|
||||
'files.upload.uploading_button': 'Téléchargement...',
|
||||
'files.upload.success': 'Fichier téléchargé avec succès!',
|
||||
'files.upload.error': 'Une erreur s\'est produite lors du téléchargement.',
|
||||
'files.upload.unexpected_error': 'Une erreur inattendue s\'est produite lors du téléchargement.',
|
||||
|
||||
// Files Page Upload Actions
|
||||
'files.drop_zone': 'Déposer les fichiers ici',
|
||||
'files.upload_button': 'Télécharger des fichiers',
|
||||
'files.uploading_button': 'Téléchargement...',
|
||||
'files.upload_aria_label': 'Télécharger des fichiers',
|
||||
|
||||
// Files Page
|
||||
'files.title': 'Fichiers',
|
||||
'files.table.title': 'Fichiers',
|
||||
'files.error.loading': 'Erreur lors du chargement des fichiers:',
|
||||
'files.button.retry': 'Réessayer',
|
||||
'files.page.tab.all': 'Tous les fichiers',
|
||||
'files.page.tab.uploads': 'Mes téléchargements',
|
||||
'files.page.tab.created': 'Fichiers créés',
|
||||
'files.page.tab.shared': 'Fichiers partagés',
|
||||
'files.page.add_file': 'Ajouter un fichier',
|
||||
'files.page.loading': 'Chargement des fichiers...',
|
||||
'files.page.error': 'Erreur:',
|
||||
|
||||
// File Table Columns
|
||||
'files.column.name': 'Nom',
|
||||
'files.column.filename': 'Nom de fichier',
|
||||
'files.column.type': 'Type',
|
||||
'files.column.mimetype': 'Type MIME',
|
||||
'files.column.size': 'Taille',
|
||||
'files.column.filesize': 'Taille du fichier',
|
||||
'files.column.created': 'Créé',
|
||||
'files.column.creationdate': 'Date de création',
|
||||
'files.column.source': 'Source',
|
||||
|
||||
// File Types
|
||||
'files.type.image': 'Image',
|
||||
'files.type.pdf': 'PDF',
|
||||
'files.type.document': 'Document',
|
||||
'files.type.spreadsheet': 'Feuille de calcul',
|
||||
'files.type.text': 'Texte',
|
||||
'files.type.video': 'Vidéo',
|
||||
'files.type.audio': 'Audio',
|
||||
'files.type.file': 'Fichier',
|
||||
|
||||
|
||||
|
||||
// File Actions
|
||||
'files.action.preview': 'Aperçu',
|
||||
'files.action.download': 'Télécharger',
|
||||
'files.action.delete': 'Supprimer',
|
||||
'files.delete.confirm': 'Êtes-vous sûr de vouloir supprimer le fichier "{name}"?',
|
||||
|
||||
// File Preview
|
||||
'files.preview.title': 'Aperçu du fichier',
|
||||
'files.preview.loading': 'Chargement de l\'aperçu...',
|
||||
'files.preview.unsupported': 'Aperçu non disponible pour ce type de fichier',
|
||||
'files.preview.error': 'Erreur lors du chargement de l\'aperçu',
|
||||
'files.preview.textInPdfFile': 'Aperçu du texte',
|
||||
'files.preview.pdfFileCorrupted': 'Ce fichier semble être corrompu. Il a une extension PDF mais contient du contenu texte. Veuillez le télécharger à nouveau si possible.',
|
||||
|
||||
// Workflows Page
|
||||
'workflows.title': 'Workflows',
|
||||
'workflows.table.title': 'Workflows',
|
||||
'workflows.error.loading': 'Erreur lors du chargement des workflows:',
|
||||
'workflows.button.retry': 'Réessayer',
|
||||
'workflows.table.empty': 'Aucun workflow trouvé',
|
||||
|
||||
// Workflow Table Columns
|
||||
'workflows.column.id': 'ID',
|
||||
'workflows.column.name': 'Nom',
|
||||
'workflows.column.status': 'Statut',
|
||||
'workflows.column.round': 'Tour',
|
||||
'workflows.column.started': 'Démarré',
|
||||
'workflows.column.lastActivity': 'Dernière activité',
|
||||
'workflows.column.messages': 'Messages',
|
||||
|
||||
// Workflow Status
|
||||
'workflows.status.running': 'En cours',
|
||||
'workflows.status.completed': 'Terminé',
|
||||
'workflows.status.failed': 'Échoué',
|
||||
'workflows.status.stopped': 'Arrêté',
|
||||
'workflows.status.pending': 'En attente',
|
||||
|
||||
// Workflow Actions
|
||||
'workflows.action.stop': 'Arrêter',
|
||||
'workflows.action.delete': 'Supprimer',
|
||||
'workflows.action.stop.tooltip': 'Arrêter le workflow',
|
||||
'workflows.action.delete.tooltip': 'Supprimer le workflow',
|
||||
|
||||
// Workflow Messages
|
||||
'workflows.unnamed': 'Workflow sans nom',
|
||||
'workflows.delete.confirm': 'Êtes-vous sûr de vouloir supprimer le workflow "{name}"?',
|
||||
'workflows.loading': 'Chargement des workflows...',
|
||||
|
||||
// FormGenerator
|
||||
'formgen.search.placeholder': 'Rechercher...',
|
||||
'formgen.refresh.tooltip': 'Actualiser les données',
|
||||
'formgen.filter.yes': 'Oui',
|
||||
'formgen.filter.no': 'Non',
|
||||
'formgen.filter.clear': 'Effacer le filtre',
|
||||
'formgen.filter.placeholder': 'Filtrer {column}',
|
||||
'formgen.actions.column': 'Actions',
|
||||
'formgen.pagination.info': 'Page {page} sur {total} ({count} éléments)',
|
||||
'formgen.pagination.pageSize': 'Éléments par page:',
|
||||
'formgen.pagination.first': 'Première page',
|
||||
'formgen.pagination.prev': 'Page précédente',
|
||||
'formgen.pagination.next': 'Page suivante',
|
||||
'formgen.pagination.last': 'Dernière page',
|
||||
'formgen.select.all': 'Sélectionner tous les éléments',
|
||||
'formgen.select.item': 'Sélectionner cet élément',
|
||||
'formgen.select.disabled': 'Cet élément ne peut pas être sélectionné',
|
||||
'formgen.delete.multiple': 'Supprimer ({count})',
|
||||
'formgen.delete.confirm_multiple': 'Êtes-vous sûr de vouloir supprimer les {count} éléments sélectionnés ?',
|
||||
|
||||
// Prompts
|
||||
'prompts.title': 'Prompts',
|
||||
'prompts.subtitle': 'Gérer vos prompts',
|
||||
'prompts.description': 'Créer et gérer des prompts pour votre assistant IA',
|
||||
'prompts.new_button': 'Nouveau prompt',
|
||||
'prompts.addNew': 'Ajouter un prompt',
|
||||
'prompts.creating': 'Création...',
|
||||
'prompts.column.name': 'Nom',
|
||||
'prompts.column.content': 'Contenu',
|
||||
'prompts.column.mandateId': 'ID Mandat',
|
||||
'prompts.unnamed': 'Sans nom',
|
||||
'prompts.action.edit': 'Modifier',
|
||||
'prompts.action.copy': 'Copier',
|
||||
'prompts.action.delete': 'Supprimer',
|
||||
'prompts.action.delete.disabled': 'Aucune permission de supprimer l\'invite',
|
||||
'prompts.delete.confirm': 'Êtes-vous sûr de vouloir supprimer "{name}" ?',
|
||||
'prompts.delete.confirmMultiple': 'Êtes-vous sûr de vouloir supprimer {count} prompts ?',
|
||||
'prompts.field.name': 'Nom du prompt',
|
||||
'prompts.field.content': 'Contenu du prompt',
|
||||
'prompts.validation.nameRequired': 'Le nom du prompt ne peut pas être vide',
|
||||
'prompts.validation.nameTooLong': 'Le nom du prompt ne peut pas dépasser 100 caractères',
|
||||
'prompts.validation.contentRequired': 'Le contenu du prompt ne peut pas être vide',
|
||||
'prompts.validation.contentTooLong': 'Le contenu du prompt ne peut pas dépasser 10 000 caractères',
|
||||
'prompts.error.loading': 'Erreur lors du chargement des prompts:',
|
||||
'prompts.modal.edit.title': 'Modifier le prompt',
|
||||
'prompts.modal.edit.save': 'Enregistrer les modifications',
|
||||
'prompts.modal.create.title': 'Créer un nouveau prompt',
|
||||
'prompts.modal.create.save': 'Créer le prompt',
|
||||
'prompts.create.success': 'Prompt créé avec succès',
|
||||
'prompts.create.error': 'Erreur lors de la création du prompt',
|
||||
|
||||
// Users/Members
|
||||
'users.title': 'Utilisateurs',
|
||||
'users.column.username': 'Nom d\'utilisateur',
|
||||
'users.column.name': 'Nom',
|
||||
'users.column.email': 'E-mail',
|
||||
'users.column.password': 'Mot de passe',
|
||||
'users.column.language': 'Langue',
|
||||
'users.column.privilege': 'Privilège',
|
||||
'users.column.enabled': 'Activé',
|
||||
'users.column.authAuthority': 'Autorité d\'authentification',
|
||||
'users.password.placeholder': 'Entrez le mot de passe',
|
||||
'users.noUsername': 'Aucun nom d\'utilisateur',
|
||||
'users.noName': 'Aucun nom',
|
||||
'users.noEmail': 'Aucun e-mail',
|
||||
'users.noLanguage': 'Aucune langue',
|
||||
'users.noPrivilege': 'Aucun privilège',
|
||||
'users.noAuthAuthority': 'Aucune autorité d\'authentification',
|
||||
'users.privilege.viewer': 'Observateur',
|
||||
'users.privilege.user': 'Utilisateur',
|
||||
'users.privilege.admin': 'Administrateur',
|
||||
'users.privilege.sysadmin': 'Administrateur système',
|
||||
'users.enabled.yes': 'Oui',
|
||||
'users.enabled.no': 'Non',
|
||||
'users.auth.local': 'Local',
|
||||
'users.auth.msft': 'Microsoft',
|
||||
'users.actions.edit': 'Modifier',
|
||||
'users.actions.delete': 'Supprimer',
|
||||
'users.edit.title': 'Modifier l\'utilisateur',
|
||||
'users.add.title': 'Ajouter un utilisateur',
|
||||
'users.add.button': 'Ajouter un utilisateur',
|
||||
'users.add.create': 'Créer l\'utilisateur',
|
||||
'users.delete.title': 'Supprimer l\'utilisateur',
|
||||
'users.delete.message': 'Êtes-vous sûr de vouloir supprimer cet utilisateur ?',
|
||||
'users.delete.confirm': 'Êtes-vous sûr de vouloir supprimer "{name}" ?',
|
||||
'users.delete.warning': 'Cette action ne peut pas être annulée.',
|
||||
'users.action.edit': 'Modifier',
|
||||
'users.action.delete': 'Supprimer',
|
||||
'users.delete.confirmMultiple': 'Êtes-vous sûr de vouloir supprimer {count} utilisateurs ?',
|
||||
'users.error.loading': 'Erreur lors du chargement des utilisateurs:',
|
||||
|
||||
// Team Members
|
||||
'team-members.title': 'Membres de l\'équipe',
|
||||
'team-members.subtitle': 'Gérer les membres de votre équipe',
|
||||
'team-members.description': 'Gérer les membres de l\'équipe, définir les permissions et configurer les paramètres de collaboration',
|
||||
'team-members.new_button': 'Ajouter un membre',
|
||||
'team-members.action.edit': 'Modifier',
|
||||
'team-members.action.delete': 'Supprimer',
|
||||
'team-members.action.sendPasswordLink': 'Envoyer le lien de mot de passe',
|
||||
'team-members.action.passwordLinkSent': 'Lien de mot de passe envoyé!',
|
||||
'team-members.action.passwordLinkFailed': 'Échec de l\'envoi du lien',
|
||||
'team-members.field.username': 'Nom d\'utilisateur',
|
||||
'team-members.field.email': 'E-mail',
|
||||
'team-members.field.password': 'Mot de passe',
|
||||
'team-members.field.fullName': 'Nom complet',
|
||||
'team-members.field.privilege': 'Privilège',
|
||||
'team-members.modal.create.title': 'Créer un nouveau membre de l\'équipe',
|
||||
'team-members.create.success': 'Membre de l\'équipe créé avec succès',
|
||||
'team-members.create.error': 'Erreur lors de la création du membre de l\'équipe',
|
||||
|
||||
// SharePoint Test
|
||||
'sharepoint.title': 'Test SharePoint',
|
||||
'sharepoint.table.title': 'Documents SharePoint',
|
||||
'sharepoint.error.loading': 'Erreur lors du chargement des documents SharePoint:',
|
||||
'sharepoint.button.retry': 'Réessayer',
|
||||
'sharepoint.button.testConnection': 'Tester la connexion',
|
||||
'sharepoint.button.listDocuments': 'Lister les documents',
|
||||
'sharepoint.button.discoverSites': 'Découvrir les sites',
|
||||
'sharepoint.column.documentName': 'Nom du document',
|
||||
'sharepoint.column.mimeType': 'Type MIME',
|
||||
'sharepoint.column.size': 'Taille',
|
||||
'sharepoint.column.path': 'Chemin',
|
||||
'sharepoint.action.view': 'Voir',
|
||||
'sharepoint.action.download': 'Télécharger',
|
||||
'sharepoint.connections.title': 'Connexions Microsoft',
|
||||
'sharepoint.connections.noConnections': 'Aucune connexion Microsoft trouvée. Veuillez d\'abord créer une connexion.',
|
||||
'sharepoint.connections.loading': 'Chargement des connexions...',
|
||||
'sharepoint.sites.discovered': 'Sites découverts',
|
||||
'sharepoint.sites.noSites': 'Aucun site SharePoint trouvé',
|
||||
'sharepoint.sites.authError': 'Token d\'authentification expiré ou invalide. Veuillez reconnecter votre compte Microsoft.',
|
||||
'sharepoint.sites.retryConnection': 'Essayez de reconnecter votre compte Microsoft dans la page Connexions.',
|
||||
'sharepoint.form.siteUrl': 'URL du site SharePoint',
|
||||
'sharepoint.form.folderPaths': 'Chemins des dossiers',
|
||||
|
||||
// Speech
|
||||
'speech.title': 'Intégration Vocale',
|
||||
'speech.subtitle': 'Alimenté par ',
|
||||
'speech.signup.title': 'Intégration Vocale',
|
||||
'speech.signup.subtitle': 'Alimenté par',
|
||||
|
||||
'speech.info.va': 'Assistant Virtuel (VA)',
|
||||
'speech.info.va_description': 'Offrez aux clients un libre-service rapide et efficace pour les requêtes vocales et textuelles disponible 24h/24.',
|
||||
'speech.info.sa': 'Analyse Vocale (SA)',
|
||||
'speech.info.sa_description': 'Surveillez automatiquement 100% des conversations pour obtenir des insights précieux pour votre entreprise.',
|
||||
'speech.info.vb': 'Biométrie Vocale (VB)',
|
||||
'speech.info.vb_description': 'Identifiez et authentifiez les appelants en quelques secondes avec une vérification et sécurité continues.',
|
||||
'speech.info.ka': 'Agent de Connaissance (KA)',
|
||||
'speech.info.ka_description': 'Unifiez et livrez des informations à vos clients et employés où et quand ils en ont besoin.',
|
||||
'speech.info.cp': 'Plateforme de Chat (CP)',
|
||||
'speech.info.cp_description': 'Offrez une assistance en chat en direct et déployez des chatbots intelligents sur tous les canaux.',
|
||||
'speech.info.aa': 'Assistance Agent (AA)',
|
||||
'speech.info.aa_description': 'Mettez tout ce dont vos agents ont besoin à portée de main, avec un bureau d\'agent unifié.',
|
||||
|
||||
'speech.info.about': 'Intégration Téléphonique Révolutionnaire avec Spitch.ai',
|
||||
'speech.info.about_intro': 'Découvrez l\'avenir de la communication client grâce à notre partenariat stratégique avec Spitch.ai. Cette intégration révolutionnaire transforme votre plateforme PowerOn en un système téléphonique intelligent qui connecte de manière transparente les clients externes avec les entreprises.',
|
||||
'speech.info.workflow_title': 'Workflow Client Transparent:',
|
||||
'speech.info.workflow_description': 'De l\'inscription à la configuration technique - votre client s\'inscrit auprès de PowerOn pour les services téléphoniques, télécharge des documents et reçoit automatiquement un numéro SIP technique de Spitch. Le transfert d\'appel peut être activé ou désactivé à tout moment, garantissant une flexibilité maximale et la sécurité BCM.',
|
||||
'speech.info.ai_title': 'Génération de Documents alimentée par l\'IA:',
|
||||
'speech.info.ai_description': 'Notre moteur d\'extraction de documents déjà actif génère automatiquement des documents personnalisés pour Spitch basés sur les données spécifiques au client. L\'IA utilise les bases de données FAQ, les informations employés et les détails de service pour rendre chaque appel contextuel et hautement personnalisé.',
|
||||
'speech.info.sync_title': 'Synchronisation de Données en Temps Réel:',
|
||||
'speech.info.sync_description': 'Spitch vérifie l\'autorisation client avec PowerOn avant chaque appel, tandis que tous les changements de données sont initiés centralement par PowerOn. Les transcriptions d\'appels sont stockées en temps réel dans votre base de données PowerOn avec une isolation complète du client et la sécurité. En cas de panne, les appels sont automatiquement bloqués pour assurer l\'intégrité.',
|
||||
'speech.info.cost_title': 'Économies de Coûts & Efficacité:',
|
||||
'speech.info.cost_description': 'Les clients peuvent basculer sur le numéro SIP technique à tout moment et économiser des coûts téléphoniques significatifs. L\'intégration fonctionne comme un autre connecteur (Outlook, SharePoint) et est intégrée de manière transparente dans votre workflow existant.',
|
||||
'speech.info.about_link': 'En savoir plus',
|
||||
|
||||
'speech.signup.button': 'Connecter',
|
||||
'speech.signup.back': 'Retour à l\'Intégration Vocale',
|
||||
'speech.signup.submit': 'Créer le Mandat',
|
||||
'speech.signup.cancel': 'Annuler',
|
||||
|
||||
'speech.signup.company_info': 'Informations de l\'Entreprise',
|
||||
'speech.signup.company_name': 'Nom de l\'Entreprise',
|
||||
'speech.signup.company_name_placeholder': 'Entrez le nom de votre entreprise',
|
||||
'speech.signup.industry': 'Secteur d\'Activité',
|
||||
'speech.signup.industry_placeholder': 'ex. Services Financiers, Technologie, etc.',
|
||||
'speech.signup.business_hours': 'Heures d\'Ouverture',
|
||||
'speech.signup.timezone': 'Fuseau Horaire',
|
||||
|
||||
'speech.signup.contact_info': 'Informations de Contact',
|
||||
'speech.signup.email': 'Adresse Email',
|
||||
'speech.signup.email_placeholder': 'contact@entreprise.com',
|
||||
'speech.signup.phone': 'Numéro de Téléphone',
|
||||
'speech.signup.phone_placeholder': '+41 123 456 789',
|
||||
'speech.signup.street': 'Rue',
|
||||
'speech.signup.postal_code': 'Code Postal',
|
||||
'speech.signup.city': 'Ville',
|
||||
'speech.signup.country': 'Pays',
|
||||
|
||||
'speech.signup.contacts_setup': 'Configurer les Contacts',
|
||||
'speech.signup.contacts_description': 'Souhaitez-vous configurer les contacts pour votre mandat maintenant ? Vous pouvez également le faire plus tard dans les paramètres.',
|
||||
'speech.signup.setup_contacts': 'Configurer les Contacts',
|
||||
'speech.signup.skip_for_now': 'Ignorer pour l\'Instant',
|
||||
|
||||
'speech.signup.company_required': 'Le nom de l\'entreprise est requis',
|
||||
'speech.signup.industry_required': 'Le secteur d\'activité est requis',
|
||||
'speech.signup.email_required': 'L\'adresse email est requise',
|
||||
'speech.signup.email_invalid': 'Veuillez entrer une adresse email valide',
|
||||
'speech.signup.phone_required': 'Le numéro de téléphone est requis',
|
||||
'speech.signup.street_required': 'La rue est requise',
|
||||
'speech.signup.postal_code_required': 'Le code postal est requis',
|
||||
'speech.signup.city_required': 'La ville est requise',
|
||||
'speech.signup.country_required': 'Le pays est requis',
|
||||
|
||||
'speech.status.submitted': '✓ Mandat Soumis',
|
||||
'speech.status.reset': 'Recommencer',
|
||||
|
||||
'speech.confirmation.title': 'Mandat Soumis avec Succès !',
|
||||
'speech.confirmation.message': 'Merci pour votre intérêt pour notre Intégration Vocale powered by Spitch.ai. Nous avons reçu votre mandat et l\'examinerons sous peu.',
|
||||
'speech.confirmation.submitted_data': 'Données Soumises :',
|
||||
'speech.confirmation.company': 'Entreprise',
|
||||
'speech.confirmation.industry': 'Secteur',
|
||||
'speech.confirmation.email': 'Email',
|
||||
'speech.confirmation.phone': 'Téléphone',
|
||||
'speech.confirmation.address': 'Adresse',
|
||||
'speech.confirmation.timezone': 'Fuseau Horaire',
|
||||
'speech.confirmation.back': 'Retour à l\'Intégration Vocale',
|
||||
'speech.confirmation.reset': 'Recommencer',
|
||||
'speech.confirmation.next_steps': 'Que se passe-t-il ensuite ?',
|
||||
'speech.confirmation.email_confirmation': 'Confirmation par Email',
|
||||
'speech.confirmation.email_confirmation_desc': 'Vous recevrez un email de confirmation dans les prochaines minutes.',
|
||||
'speech.confirmation.review_process': 'Processus de Révision',
|
||||
'speech.confirmation.review_process_desc': 'Notre équipe examinera votre mandat dans les 1-2 jours ouvrables.',
|
||||
'speech.confirmation.setup_call': 'Appel de Configuration',
|
||||
'speech.confirmation.setup_call_desc': 'Si approuvé, nous planifierons un appel de configuration pour configurer votre intégration.',
|
||||
'speech.confirmation.questions': 'Questions ?',
|
||||
'speech.confirmation.questions_desc': 'Si vous avez des questions sur votre mandat ou le processus d\'intégration, n\'hésitez pas à contacter notre équipe de support.',
|
||||
'speech.confirmation.transcript_management': 'Gestion des Transcriptions',
|
||||
'speech.confirmation.speech_settings': 'Paramètres Vocaux',
|
||||
|
||||
'speech.transcripts.title': 'Gestion des Transcriptions',
|
||||
'speech.transcripts.new_transcript': 'Nouvelle Transcription',
|
||||
'speech.transcripts.recent_transcripts': 'Transcriptions Récentes',
|
||||
'speech.transcripts.no_transcripts': 'Aucune transcription disponible',
|
||||
'speech.transcripts.date': 'Date',
|
||||
'speech.transcripts.duration': 'Durée',
|
||||
'speech.transcripts.status': 'Statut',
|
||||
'speech.transcripts.transcript': 'Transcription',
|
||||
'speech.transcripts.processing': 'Traitement de la transcription...',
|
||||
'speech.transcripts.status.completed': 'Terminé',
|
||||
'speech.transcripts.status.processing': 'En cours',
|
||||
'speech.transcripts.status.failed': 'Échoué',
|
||||
'speech.transcripts.access_denied_title': 'Accès Refusé',
|
||||
'speech.transcripts.access_denied_message': 'Vous devez d\'abord vous inscrire à l\'intégration vocale pour accéder à la gestion des transcriptions.',
|
||||
'speech.transcripts.sign_up_now': 'S\'inscrire Maintenant',
|
||||
'speech.transcripts.subject': 'Sujet',
|
||||
'speech.transcripts.start_time': 'Heure de Début',
|
||||
'speech.transcripts.end_time': 'Heure de Fin',
|
||||
'speech.transcripts.caller': 'Appelant',
|
||||
'speech.transcripts.recipient': 'Destinataire',
|
||||
'speech.transcripts.tags': 'Étiquettes',
|
||||
'speech.transcripts.created': 'Créé',
|
||||
'speech.transcripts.view': 'Voir',
|
||||
'speech.transcripts.download': 'Télécharger',
|
||||
|
||||
'speech.settings.title': 'Paramètres d\'Intégration Vocale',
|
||||
'speech.settings.description': 'Gérez votre configuration et vos préférences d\'intégration vocale.',
|
||||
'speech.settings.company_info': 'Informations de l\'Entreprise',
|
||||
'speech.settings.contact_info': 'Informations de Contact',
|
||||
'speech.settings.business_hours': 'Heures d\'Ouverture et Fuseau Horaire',
|
||||
'speech.settings.save': 'Sauvegarder les Modifications',
|
||||
'speech.settings.saving': 'Sauvegarde...',
|
||||
'speech.settings.save_success': 'Paramètres sauvegardés avec succès !',
|
||||
'speech.settings.save_error': 'Échec de la sauvegarde des paramètres. Veuillez réessayer.',
|
||||
'speech.settings.reset': 'Réinitialiser par Défaut',
|
||||
'speech.settings.reset_confirm': 'Êtes-vous sûr de vouloir réinitialiser tous les paramètres d\'intégration vocale ? Cette action ne peut pas être annulée.',
|
||||
'speech.settings.reset_success': 'Les paramètres ont été réinitialisés avec succès.',
|
||||
'speech.settings.no_data': 'Aucune donnée d\'intégration vocale trouvée. Veuillez d\'abord vous inscrire pour accéder aux paramètres.',
|
||||
'speech.settings.sign_up_now': 'S\'inscrire Maintenant',
|
||||
|
||||
// Message Overlay Types
|
||||
'message.success.title': 'Succès',
|
||||
'message.success.upload': 'Fichier téléchargé avec succès !',
|
||||
'message.info.title': 'Information',
|
||||
'message.info.processing': 'Traitement de votre demande...',
|
||||
'message.error.title': 'Erreur',
|
||||
'message.error.upload_failed': 'Échec du téléchargement. Veuillez réessayer.',
|
||||
|
||||
// Warning Messages
|
||||
'warning.duplicate_file.title': 'Fichier Déjà Existant',
|
||||
'warning.duplicate_file.message': 'Le fichier "{fileName}" existe déjà avec un contenu identique. Le fichier existant sera réutilisé.',
|
||||
|
||||
// Automations Page
|
||||
'automations.title': 'Automatisations',
|
||||
'automations.description': 'Gérer les automatisations de workflow',
|
||||
'automations.subtitle': 'Workflows planifiés et automatisés',
|
||||
'automations.new_button': 'Nouvelle Automatisation',
|
||||
'automations.action.execute': 'Exécuter',
|
||||
'automations.action.edit': 'Modifier',
|
||||
'automations.action.delete': 'Supprimer',
|
||||
'automations.modal.create.title': 'Créer une Nouvelle Automatisation',
|
||||
'automations.create.success': 'Automatisation créée avec succès',
|
||||
'automations.create.error': 'Erreur lors de la création de l\'automatisation',
|
||||
|
||||
// Basedata Group (formerly Administration)
|
||||
'basedata.title': 'Données de Base',
|
||||
'basedata.description': 'Données et ressources de base',
|
||||
|
||||
// Administration (legacy, kept for compatibility)
|
||||
'administration.title': 'Outils',
|
||||
'administration.description': 'Outils et utilitaires',
|
||||
'administration.subtitle': 'Outils d\'administration et de gestion',
|
||||
'administration.intro.description': 'Cette section contient tous les outils d\'administration et de gestion pour votre espace de travail.',
|
||||
'administration.features.title': 'Outils Disponibles',
|
||||
'administration.features.description': 'Les outils de gestion incluent:',
|
||||
'administration.features.file_management': 'Gestion des Fichiers - Télécharger et organiser les documents',
|
||||
'administration.features.user_management': 'Gestion des Utilisateurs - Gérer les membres de l\'équipe et les permissions',
|
||||
'administration.features.system_settings': 'Paramètres Système - Configurer les paramètres de l\'espace de travail',
|
||||
'administration.features.data_management': 'Gestion des Données - Gérer les imports et exports de données',
|
||||
|
||||
// Admin pages
|
||||
'admin.mandates.title': 'Mandats',
|
||||
'admin.mandates.subtitle': 'Gérer les mandats et les permissions',
|
||||
'admin.mandates.description': 'Gestion des mandats',
|
||||
'admin.mandates.description_text': 'Gérez les mandats et leurs permissions associées.',
|
||||
'admin.mandates.new_button': 'Ajouter un mandat',
|
||||
'admin.mandates.action.edit': 'Modifier',
|
||||
'admin.mandates.action.delete': 'Supprimer',
|
||||
'admin.mandates.modal.create.title': 'Créer un nouveau mandat',
|
||||
'admin.mandates.create.success': 'Mandat créé avec succès',
|
||||
'admin.mandates.create.error': 'Erreur lors de la création du mandat',
|
||||
|
||||
'admin.rbac-rules.title': 'Règles RBAC',
|
||||
'admin.rbac-rules.subtitle': 'Règles de contrôle d\'accès basé sur les rôles',
|
||||
'admin.rbac-rules.description': 'Gestion des règles RBAC',
|
||||
'admin.rbac-rules.description_text': 'Configurez et gérez les règles de contrôle d\'accès basé sur les rôles.',
|
||||
'admin.rbac-rules.new_button': 'Ajouter une règle RBAC',
|
||||
'admin.rbac-rules.action.edit': 'Modifier',
|
||||
'admin.rbac-rules.action.delete': 'Supprimer',
|
||||
'admin.rbac-rules.modal.create.title': 'Créer une nouvelle règle RBAC',
|
||||
'admin.rbac-rules.create.success': 'Règle RBAC créée avec succès',
|
||||
'admin.rbac-rules.create.error': 'Erreur lors de la création de la règle RBAC',
|
||||
|
||||
'admin.rbac-role.title': 'Rôles RBAC',
|
||||
'admin.rbac-role.subtitle': 'Gestion des rôles',
|
||||
'admin.rbac-role.description': 'Gestion des rôles RBAC',
|
||||
'admin.rbac-role.description_text': 'Créez et gérez les rôles RBAC et leurs permissions.',
|
||||
'admin.rbac-role.new_button': 'Ajouter un rôle',
|
||||
'admin.rbac-role.action.edit': 'Modifier',
|
||||
'admin.rbac-role.action.delete': 'Supprimer',
|
||||
'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle',
|
||||
'admin.rbac-role.create.success': 'Rôle créé avec succès',
|
||||
'admin.rbac-role.create.error': 'Erreur lors de la création du rôle',
|
||||
|
||||
'admin.admin-settings.title': 'Paramètres Admin',
|
||||
'admin.admin-settings.subtitle': 'Paramètres administratifs',
|
||||
'admin.admin-settings.description': 'Paramètres administratifs',
|
||||
'admin.admin-settings.description_text': 'Configurez les paramètres administratifs et les préférences système.',
|
||||
|
||||
// Start page
|
||||
'start.title': 'Démarrage',
|
||||
'start.description': 'Bienvenue dans votre espace de travail',
|
||||
'start.subtitle': 'Bienvenue dans votre espace de travail',
|
||||
'start.intro.description': 'Ceci est votre point de départ pour accéder à toutes les fonctionnalités et outils de votre espace de travail.',
|
||||
'start.features.title': 'Accès Rapide',
|
||||
'start.features.description': 'Commencez avec :',
|
||||
'start.features.quick_access': 'Accès Rapide - Accédez rapidement aux fonctionnalités fréquemment utilisées',
|
||||
'start.features.recent_activities': 'Activités Récentes - Consultez votre travail le plus récent',
|
||||
'start.features.overview': 'Aperçu - Consultez le statut et les mises à jour de l\'espace de travail',
|
||||
'start.features.navigation': 'Navigation - Explorez tous les outils disponibles',
|
||||
|
||||
// Projects page
|
||||
'projects.title': 'Projets',
|
||||
'projects.subtitle': 'Gestion de projets',
|
||||
'projects.description': 'Gestion et organisation de projets',
|
||||
'projects.description_text': 'Recherchez des emplacements par adresse ou coordonnées, ou utilisez le langage naturel pour créer et gérer des projets.',
|
||||
'projects.command.placeholder': 'Entrez une commande (par exemple, "Créer un nouveau projet nommé \'Rue Principale 42\'")',
|
||||
'projects.command.empty': 'Aucune commande exécutée pour le moment. Envoyez une commande pour voir les résultats ici.',
|
||||
|
||||
// Data Management page
|
||||
'data-management.title': 'Gestion des données',
|
||||
'data-management.subtitle': 'Gestion des données',
|
||||
'data-management.description': 'Gestion des données avec des tableaux',
|
||||
'data-management.description_text': 'Gérez les données via des tableaux. Sélectionnez un tableau ou utilisez le langage naturel pour exécuter des commandes.',
|
||||
'data-management.command.placeholder': 'Entrez une commande (par exemple, "Créer un nouveau projet nommé \'Rue Principale 42\'")',
|
||||
'data-management.command.empty': 'Aucune commande exécutée pour le moment. Envoyez une commande pour voir les résultats ici.',
|
||||
|
||||
// Drag and Drop
|
||||
'dragdrop.overlay.default_text': 'Déposer les fichiers ici',
|
||||
'dragdrop.overlay.default_subtext': 'Vous pouvez aussi cliquer sur le bouton de téléchargement',
|
||||
'dragdrop.overlay.processing': 'Traitement des fichiers...',
|
||||
'dragdrop.overlay.error': 'Erreur lors du traitement des fichiers',
|
||||
|
||||
// Trustee Feature
|
||||
'trustee.title': 'Fiduciaire',
|
||||
'trustee.subtitle': 'Gestion Fiduciaire',
|
||||
'trustee.description': 'Gestion des organisations fiduciaires, contrats et réservations',
|
||||
|
||||
// Trustee Organisations
|
||||
'trustee.organisations.title': 'Organisations',
|
||||
'trustee.organisations.subtitle': 'Gérer les organisations fiduciaires',
|
||||
'trustee.organisations.description': 'Gestion des organisations fiduciaires',
|
||||
'trustee.organisations.new_button': 'Nouvelle Organisation',
|
||||
'trustee.organisations.field.id': 'ID',
|
||||
'trustee.organisations.field.id_placeholder': 'ex. fiduciaire-ag-zurich',
|
||||
'trustee.organisations.field.label': 'Libellé',
|
||||
'trustee.organisations.field.label_placeholder': 'ex. Fiduciaire AG Zurich',
|
||||
'trustee.organisations.field.enabled': 'Activé',
|
||||
'trustee.organisations.modal.create.title': 'Créer une nouvelle organisation',
|
||||
'trustee.organisations.create.success': 'Organisation créée avec succès',
|
||||
'trustee.organisations.create.error': 'Erreur lors de la création de l\'organisation',
|
||||
'trustee.organisations.action.edit': 'Modifier',
|
||||
'trustee.organisations.action.delete': 'Supprimer',
|
||||
|
||||
// Trustee Roles
|
||||
'trustee.roles.title': 'Rôles',
|
||||
'trustee.roles.subtitle': 'Gérer les rôles fiduciaires',
|
||||
'trustee.roles.description': 'Gestion des rôles spécifiques à la fonctionnalité',
|
||||
'trustee.roles.new_button': 'Nouveau Rôle',
|
||||
'trustee.roles.field.id': 'ID du rôle',
|
||||
'trustee.roles.field.id_placeholder': 'ex. admin, operate, userreport',
|
||||
'trustee.roles.field.desc': 'Description',
|
||||
'trustee.roles.field.desc_placeholder': 'Description du rôle',
|
||||
'trustee.roles.modal.create.title': 'Créer un nouveau rôle',
|
||||
'trustee.roles.create.success': 'Rôle créé avec succès',
|
||||
'trustee.roles.create.error': 'Erreur lors de la création du rôle',
|
||||
'trustee.roles.action.edit': 'Modifier',
|
||||
'trustee.roles.action.delete': 'Supprimer',
|
||||
|
||||
// Trustee Access
|
||||
'trustee.access.title': 'Accès',
|
||||
'trustee.access.subtitle': 'Gérer les accès utilisateurs',
|
||||
'trustee.access.description': 'Gestion des accès utilisateurs aux organisations',
|
||||
'trustee.access.new_button': 'Nouvel Accès',
|
||||
'trustee.access.field.organisationId': 'Organisation',
|
||||
'trustee.access.field.roleId': 'Rôle',
|
||||
'trustee.access.field.userId': 'Utilisateur',
|
||||
'trustee.access.field.contractId': 'Contrat (optionnel)',
|
||||
'trustee.access.field.contractId_placeholder': 'Vide = Accès à tous les contrats',
|
||||
'trustee.access.modal.create.title': 'Créer un nouvel accès',
|
||||
'trustee.access.create.success': 'Accès créé avec succès',
|
||||
'trustee.access.create.error': 'Erreur lors de la création de l\'accès',
|
||||
'trustee.access.action.edit': 'Modifier',
|
||||
'trustee.access.action.delete': 'Supprimer',
|
||||
|
||||
// Trustee Contracts
|
||||
'trustee.contracts.title': 'Contrats',
|
||||
'trustee.contracts.subtitle': 'Gérer les contrats clients',
|
||||
'trustee.contracts.description': 'Gestion des contrats clients',
|
||||
'trustee.contracts.new_button': 'Nouveau Contrat',
|
||||
'trustee.contracts.field.organisationId': 'Organisation',
|
||||
'trustee.contracts.field.label': 'Libellé',
|
||||
'trustee.contracts.field.label_placeholder': 'ex. Muster AG 2026',
|
||||
'trustee.contracts.field.enabled': 'Activé',
|
||||
'trustee.contracts.modal.create.title': 'Créer un nouveau contrat',
|
||||
'trustee.contracts.create.success': 'Contrat créé avec succès',
|
||||
'trustee.contracts.create.error': 'Erreur lors de la création du contrat',
|
||||
'trustee.contracts.action.edit': 'Modifier',
|
||||
'trustee.contracts.action.delete': 'Supprimer',
|
||||
|
||||
// Trustee Documents
|
||||
'trustee.documents.title': 'Documents',
|
||||
'trustee.documents.subtitle': 'Gérer les pièces justificatives',
|
||||
'trustee.documents.description': 'Gestion des documents et pièces justificatives',
|
||||
'trustee.documents.new_button': 'Nouveau Document',
|
||||
'trustee.documents.field.organisationId': 'Organisation',
|
||||
'trustee.documents.field.contractId': 'Contrat',
|
||||
'trustee.documents.field.documentName': 'Nom du fichier',
|
||||
'trustee.documents.field.documentName_placeholder': 'ex. Justificatif.pdf',
|
||||
'trustee.documents.field.documentMimeType': 'Type de fichier',
|
||||
'trustee.documents.modal.create.title': 'Créer un nouveau document',
|
||||
'trustee.documents.create.success': 'Document créé avec succès',
|
||||
'trustee.documents.create.error': 'Erreur lors de la création du document',
|
||||
'trustee.documents.action.edit': 'Modifier',
|
||||
'trustee.documents.action.delete': 'Supprimer',
|
||||
'trustee.documents.action.download': 'Télécharger',
|
||||
|
||||
// Trustee Positions
|
||||
'trustee.positions.title': 'Positions',
|
||||
'trustee.positions.subtitle': 'Gérer les positions de réservation',
|
||||
'trustee.positions.description': 'Gestion des positions de réservation (entrées de dépenses)',
|
||||
'trustee.positions.new_button': 'Nouvelle Position',
|
||||
'trustee.positions.field.organisationId': 'Organisation',
|
||||
'trustee.positions.field.contractId': 'Contrat',
|
||||
'trustee.positions.field.valuta': 'Date de valeur',
|
||||
'trustee.positions.field.company': 'Entreprise',
|
||||
'trustee.positions.field.company_placeholder': 'Nom de l\'entreprise',
|
||||
'trustee.positions.field.desc': 'Description',
|
||||
'trustee.positions.field.bookingCurrency': 'Devise de comptabilisation',
|
||||
'trustee.positions.field.bookingAmount': 'Montant de comptabilisation',
|
||||
'trustee.positions.field.originalCurrency': 'Devise d\'origine',
|
||||
'trustee.positions.field.originalAmount': 'Montant d\'origine',
|
||||
'trustee.positions.field.vatPercentage': 'TVA %',
|
||||
'trustee.positions.field.vatAmount': 'Montant TVA',
|
||||
'trustee.positions.modal.create.title': 'Créer une nouvelle position',
|
||||
'trustee.positions.create.success': 'Position créée avec succès',
|
||||
'trustee.positions.create.error': 'Erreur lors de la création de la position',
|
||||
'trustee.positions.action.edit': 'Modifier',
|
||||
'trustee.positions.action.delete': 'Supprimer',
|
||||
};
|
||||
|
|
@ -1,37 +1,34 @@
|
|||
import { Language, TranslationKeys } from './types';
|
||||
import { getApiBaseUrl } from '../../config/config';
|
||||
|
||||
// Dynamic language loader
|
||||
export const loadLanguage = async (language: Language): Promise<TranslationKeys> => {
|
||||
try {
|
||||
let translations: TranslationKeys;
|
||||
export interface I18nCodeInfo {
|
||||
code: string;
|
||||
label?: string;
|
||||
status?: string;
|
||||
isDefault?: boolean;
|
||||
keysCount?: number;
|
||||
}
|
||||
|
||||
switch (language) {
|
||||
case 'de':
|
||||
const de = await import('./de');
|
||||
translations = de.default;
|
||||
break;
|
||||
case 'en':
|
||||
const en = await import('./en');
|
||||
translations = en.default;
|
||||
break;
|
||||
case 'fr':
|
||||
const fr = await import('./fr');
|
||||
translations = fr.default;
|
||||
break;
|
||||
default:
|
||||
// Fallback to German if language not found
|
||||
const fallback = await import('./de');
|
||||
translations = fallback.default;
|
||||
}
|
||||
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load language ${language}:`, error);
|
||||
// Fallback to German in case of error
|
||||
const fallback = await import('./de');
|
||||
return fallback.default;
|
||||
export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> {
|
||||
const base = getApiBaseUrl().replace(/\/$/, '');
|
||||
const res = await fetch(`${base}/api/i18n/codes`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`i18n/codes failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const loadLanguage = async (language: Language): Promise<TranslationKeys> => {
|
||||
const base = getApiBaseUrl().replace(/\/$/, '');
|
||||
const code = String(language || 'de').trim() || 'de';
|
||||
const res = await fetch(`${base}/api/i18n/sets/${encodeURIComponent(code)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load language ${code}: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { keys?: TranslationKeys };
|
||||
return data.keys ?? {};
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
export type { Language, TranslationKeys } from './types';
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
// Language type definition
|
||||
export type Language = 'de' | 'en' | 'fr';
|
||||
export type Language = string;
|
||||
|
||||
// Translation keys and their values
|
||||
export type TranslationKeys = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
// Language loader interface
|
||||
export interface LanguageLoader {
|
||||
loadLanguage: (language: Language) => Promise<TranslationKeys>;
|
||||
}
|
||||
|
|
@ -1,24 +1,40 @@
|
|||
/**
|
||||
* GraphicalEditorDashboardPage
|
||||
* AutomationsDashboardPage
|
||||
*
|
||||
* Overview dashboard with metric cards and recent runs table.
|
||||
* Uses FormGeneratorTable for the runs list.
|
||||
* System-level dashboard for workflow runs across all features and mandates.
|
||||
* Uses /api/system/workflow-runs endpoints with RBAC scoping.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { FaSync, FaPlay, FaCog, FaClipboardList, FaChartBar, FaDownload } from 'react-icons/fa';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import {
|
||||
fetchMetrics,
|
||||
fetchCompletedRuns,
|
||||
type WorkflowMetrics,
|
||||
type CompletedRun,
|
||||
} from '../../../api/workflowApi';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import styles from '../../../pages/admin/Admin.module.css';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload } from 'react-icons/fa';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatUnixTimestamp } from '../utils/time';
|
||||
import api from '../api';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import styles from './admin/Admin.module.css';
|
||||
|
||||
interface WorkflowRunMetrics {
|
||||
totalRuns: number;
|
||||
runsByStatus: Record<string, number>;
|
||||
totalTokens: number;
|
||||
totalCredits: number;
|
||||
workflowCount: number;
|
||||
activeWorkflows: number;
|
||||
}
|
||||
|
||||
interface WorkflowRun {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
workflowLabel?: string;
|
||||
mandateId?: string;
|
||||
ownerId?: string;
|
||||
status: string;
|
||||
costTokens?: number;
|
||||
costCredits?: number;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
}
|
||||
|
||||
function _formatTs(ts?: number): string {
|
||||
if (ts == null || ts <= 0) return '—';
|
||||
|
|
@ -33,7 +49,7 @@ function _formatTs(ts?: number): string {
|
|||
return time;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
const _STATUS_COLORS: Record<string, string> = {
|
||||
completed: 'var(--success-color, #28a745)',
|
||||
failed: 'var(--danger-color, #dc3545)',
|
||||
running: 'var(--primary-color, #007bff)',
|
||||
|
|
@ -62,14 +78,7 @@ const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) =>
|
|||
flex: '1 1 180px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: color || 'var(--primary-color, #007bff)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 22, color: color || 'var(--primary-color, #007bff)', display: 'flex', alignItems: 'center' }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -79,45 +88,40 @@ const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) =>
|
|||
</div>
|
||||
);
|
||||
|
||||
export const GraphicalEditorDashboardPage: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const { request } = useApiRequest();
|
||||
export const AutomationsDashboardPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showError } = useToast();
|
||||
|
||||
const [metrics, setMetrics] = useState<WorkflowMetrics | null>(null);
|
||||
const [recentRuns, setRecentRuns] = useState<CompletedRun[]>([]);
|
||||
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
const _load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [m, runs] = await Promise.all([
|
||||
fetchMetrics(request, instanceId),
|
||||
fetchCompletedRuns(request, instanceId, 30),
|
||||
const [metricsResp, runsResp] = await Promise.all([
|
||||
api.get('/api/system/workflow-runs/metrics'),
|
||||
api.get('/api/system/workflow-runs', { params: { limit: 50 } }),
|
||||
]);
|
||||
setMetrics(m);
|
||||
setRecentRuns(runs);
|
||||
setMetrics(metricsResp.data);
|
||||
setRuns(runsResp.data?.runs || []);
|
||||
} catch (e) {
|
||||
console.error('[graphicalEditor] dashboard load failed', e);
|
||||
showError('Fehler beim Laden des Dashboards');
|
||||
console.error('[automations] dashboard load failed', e);
|
||||
showError(t('Fehler beim Laden des Automations-Dashboards'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request, showError]);
|
||||
}, [showError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _downloadRunTracing = useCallback(async (run: CompletedRun) => {
|
||||
if (!instanceId || !run.id) return;
|
||||
const _downloadRunTracing = useCallback(async (run: WorkflowRun) => {
|
||||
if (!run.id) return;
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/runs/${run.id}/steps`,
|
||||
method: 'get',
|
||||
});
|
||||
const steps = data?.steps || [];
|
||||
const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`);
|
||||
const steps = resp.data?.steps || [];
|
||||
const report = {
|
||||
runId: run.id,
|
||||
workflowId: run.workflowId,
|
||||
|
|
@ -135,41 +139,53 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
|
|||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('[dashboard] download tracing failed', e);
|
||||
showError('Download fehlgeschlagen');
|
||||
console.error('[automations] download tracing failed', e);
|
||||
showError(t('Download fehlgeschlagen'));
|
||||
}
|
||||
}, [instanceId, request, showError]);
|
||||
}, [showError, t]);
|
||||
|
||||
const runColumns: ColumnConfig[] = [
|
||||
const _runColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'workflowLabel',
|
||||
label: 'Workflow',
|
||||
label: t('Workflow'),
|
||||
type: 'string',
|
||||
width: 200,
|
||||
sortable: true,
|
||||
formatter: (v: string, row: CompletedRun) => v || row.workflowId || '—',
|
||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||
},
|
||||
{
|
||||
key: 'mandateId',
|
||||
label: t('Mandant'),
|
||||
type: 'string',
|
||||
width: 120,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: string) => v ? v.slice(0, 8) + '…' : t('—'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
label: t('Status'),
|
||||
type: 'string',
|
||||
width: 110,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
formatter: (v: string) => (
|
||||
<span style={{ color: STATUS_COLORS[v] || 'inherit', fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
label: 'Gestartet',
|
||||
label: t('Gestartet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
sortable: true,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
},
|
||||
{
|
||||
key: 'sysModifiedAt',
|
||||
label: 'Beendet',
|
||||
label: t('Beendet'),
|
||||
type: 'number',
|
||||
width: 150,
|
||||
formatter: (v: number) => _formatTs(v),
|
||||
|
|
@ -180,10 +196,10 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
|
|||
type: 'string',
|
||||
width: 50,
|
||||
sortable: false,
|
||||
formatter: (_v: string, row: CompletedRun) => (
|
||||
formatter: (_v: string, row: WorkflowRun) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); _downloadRunTracing(row); }}
|
||||
title="Tracing-Protokoll herunterladen"
|
||||
title={t('Tracing-Protokoll herunterladen')}
|
||||
style={{
|
||||
border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
color: 'var(--text-secondary, #666)', fontSize: 14, padding: 4,
|
||||
|
|
@ -194,56 +210,28 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
|
|||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<p>Keine Feature-Instanz gefunden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
], [t, _downloadRunTracing]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Dashboard</h1>
|
||||
<p className={styles.pageSubtitle}>Übersicht über Workflows, Runs und Ressourcen</p>
|
||||
<h1 className={styles.pageTitle}>{t('Automations')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.secondaryButton} onClick={() => load()} disabled={loading}>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Cards */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24 }}>
|
||||
<MetricCard
|
||||
icon={<FaCog />}
|
||||
label="Workflows"
|
||||
value={metrics?.workflowCount ?? '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<FaPlay />}
|
||||
label="Aktive Workflows"
|
||||
value={metrics?.activeWorkflows ?? '—'}
|
||||
color="var(--success-color, #28a745)"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<FaChartBar />}
|
||||
label="Runs gesamt"
|
||||
value={metrics?.totalRuns ?? '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<FaClipboardList />}
|
||||
label="Tasks gesamt"
|
||||
value={metrics?.totalTasks ?? '—'}
|
||||
/>
|
||||
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
|
||||
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
|
||||
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
|
||||
</div>
|
||||
|
||||
{/* Runs by Status */}
|
||||
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>Runs nach Status</h3>
|
||||
|
|
@ -257,7 +245,7 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
|
|||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
background: 'var(--bg-secondary, #f5f5f5)',
|
||||
color: STATUS_COLORS[status] || 'inherit',
|
||||
color: _STATUS_COLORS[status] || 'inherit',
|
||||
}}
|
||||
>
|
||||
{status}: {count}
|
||||
|
|
@ -267,40 +255,40 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost summary */}
|
||||
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
|
||||
<div style={{ marginBottom: 24, display: 'flex', gap: 24 }}>
|
||||
{metrics.totalTokens > 0 && (
|
||||
<div>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>Tokens gesamt: </span>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
||||
<strong>{metrics.totalTokens.toLocaleString('de-DE')}</strong>
|
||||
</div>
|
||||
)}
|
||||
{metrics.totalCredits > 0 && (
|
||||
<div>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>Credits gesamt: </span>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Credits gesamt:')} </span>
|
||||
<strong>{metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Runs Table */}
|
||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>Letzte Runs</h3>
|
||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Letzte Runs')}</h3>
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable<CompletedRun>
|
||||
data={recentRuns}
|
||||
columns={runColumns}
|
||||
<FormGeneratorTable<WorkflowRun>
|
||||
data={runs}
|
||||
columns={_runColumns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={15}
|
||||
searchable={true}
|
||||
filterable={false}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
emptyMessage="Noch keine Runs vorhanden."
|
||||
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomationsDashboardPage;
|
||||
|
|
@ -6,11 +6,10 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
||||
import { useNavigation } from '../hooks/useNavigation';
|
||||
import type { FeatureView as FeatureViewDef } from '../hooks/useNavigation';
|
||||
|
||||
// Trustee Views
|
||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||
|
|
@ -33,8 +32,6 @@ import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage
|
|||
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
|
||||
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
||||
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
||||
import { GraphicalEditorDashboardPage } from './views/graphicalEditor/GraphicalEditorDashboardPage';
|
||||
|
||||
// Workspace Views
|
||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||
|
|
@ -134,7 +131,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
workflows: GraphicalEditorWorkflowsPage,
|
||||
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
||||
templates: GraphicalEditorTemplatesPage,
|
||||
dashboard: GraphicalEditorDashboardPage,
|
||||
},
|
||||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
|
|
@ -168,9 +164,8 @@ interface FeatureViewPageProps {
|
|||
}
|
||||
|
||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||
const { currentLanguage } = useLanguage();
|
||||
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
|
||||
const { instance, featureCode, instanceId, isValid } = useCurrentInstance();
|
||||
const { blocks } = useNavigation();
|
||||
|
||||
// Berechtigungs-Check
|
||||
const viewCode = `${featureCode}-${view}`;
|
||||
|
|
@ -235,11 +230,21 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
return <NotFound />;
|
||||
}
|
||||
|
||||
// View-Info aus Registry
|
||||
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||
const viewConfig = featureConfig?.views?.find(v => v.code === view);
|
||||
const lang = (currentLanguage?.slice(0, 2) || 'de') as 'de' | 'en' | 'fr';
|
||||
const viewLabel = viewConfig ? getLabel(viewConfig.label, lang) : view;
|
||||
let viewLabel = view;
|
||||
for (const block of blocks) {
|
||||
if (block.type !== 'dynamic') continue;
|
||||
for (const mandate of (block as any).mandates || []) {
|
||||
for (const feat of mandate.features || []) {
|
||||
for (const inst of feat.instances || []) {
|
||||
if (inst.id !== instanceId) continue;
|
||||
const vDef: FeatureViewDef | undefined = inst.views?.find(
|
||||
(v: FeatureViewDef) => v.uiComponent?.endsWith(`.${view}`)
|
||||
);
|
||||
if (vDef?.uiLabel) viewLabel = vDef.uiLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.featureView}>
|
||||
|
|
|
|||
|
|
@ -41,11 +41,21 @@ interface ProfileEditModalProps {
|
|||
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { availableLanguages } = useLanguage();
|
||||
|
||||
const languageOptions =
|
||||
availableLanguages.length > 0
|
||||
? availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }))
|
||||
: [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
];
|
||||
|
||||
const profileAttributes: AttributeDefinition[] = [
|
||||
{ name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' },
|
||||
{ name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' },
|
||||
{ name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] },
|
||||
{ name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: languageOptions },
|
||||
];
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
|
|
@ -429,7 +439,7 @@ const NeutralizationMappingsTab: React.FC = () => {
|
|||
// =============================================================================
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { currentLanguage, setLanguage } = useLanguage();
|
||||
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
|
||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||
const { updateUser } = useUser();
|
||||
|
||||
|
|
@ -447,7 +457,7 @@ export const SettingsPage: React.FC = () => {
|
|||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
|
||||
const handleLanguageChange = useCallback(async (newLanguage: string) => {
|
||||
if (!currentUser?.id || !currentUser?.username) return;
|
||||
setIsSavingLanguage(true);
|
||||
setLanguageError(null);
|
||||
|
|
@ -467,7 +477,7 @@ export const SettingsPage: React.FC = () => {
|
|||
const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
|
||||
const cachedUser = getUserDataCache();
|
||||
if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage });
|
||||
if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr');
|
||||
if (newLanguage !== currentLanguage) setLanguage(newLanguage);
|
||||
if (refetchUser) await refetchUser();
|
||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
||||
|
|
@ -538,8 +548,19 @@ export const SettingsPage: React.FC = () => {
|
|||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>Anzeigesprache</label><p className={styles.settingDescription}>Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
||||
<div className={styles.settingControl}>
|
||||
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
|
||||
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value)} disabled={isSavingLanguage}>
|
||||
{(availableLanguages.length > 0
|
||||
? availableLanguages
|
||||
: [
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
]
|
||||
).map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label || l.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
|
|
|
|||
294
src/pages/admin/AdminLanguagesPage.tsx
Normal file
294
src/pages/admin/AdminLanguagesPage.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* SysAdmin: UI language sets (DB-backed i18n).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FaDownload, FaFileExport, FaFileImport, FaRedo, 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;
|
||||
keysCount: number;
|
||||
is?: boolean;
|
||||
};
|
||||
|
||||
const _columns: ColumnConfig[] = [
|
||||
{ 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: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'keysCount', label: 'Keys', type: 'number', sortable: true, width: 90 },
|
||||
];
|
||||
|
||||
const _isoChoices = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
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('it');
|
||||
const [addLabel, setAddLabel] = useState('Italiano');
|
||||
|
||||
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 || '',
|
||||
keysCount: r.keysCount ?? 0,
|
||||
})),
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message || 'Laden fehlgeschlagen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _updateOne = async (code: string) => {
|
||||
try {
|
||||
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const _updateAll = async () => {
|
||||
const ok = await confirm(t('Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?'), {
|
||||
confirmLabel: t('Alle aktualisieren'),
|
||||
cancelLabel: t('Abbrechen'),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.put('/api/i18n/sets/update-all');
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const _delete = async (code: string) => {
|
||||
if (code === 'de') 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 () => {
|
||||
const code = String(addCode).trim().toLowerCase();
|
||||
const label = String(addLabel).trim();
|
||||
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;
|
||||
try {
|
||||
await api.post('/api/i18n/sets', { code, label });
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
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);
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
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) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const existingCodes = new Set(rows.map((r) => r.id));
|
||||
const addChoices = _isoChoices.filter((c) => !existingCodes.has(c.value));
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem' }}>
|
||||
<header>
|
||||
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
||||
{error && (
|
||||
<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.primaryButton} onClick={_updateAll}>
|
||||
{t('Alle aktualisieren')}
|
||||
</button>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_exportAll}>
|
||||
<FaFileExport /> {t('Export')}
|
||||
</button>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_importFile}>
|
||||
<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' }}
|
||||
>
|
||||
{addChoices.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<FormGeneratorTable
|
||||
data={rows}
|
||||
columns={_columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
selectable={false}
|
||||
customActions={[
|
||||
{
|
||||
id: 'upd',
|
||||
title: t('Aktualisieren'),
|
||||
icon: <FaRedo />,
|
||||
onClick: (row: LangRow) => _updateOne(row.id),
|
||||
visible: (row: LangRow) => row.id !== 'de',
|
||||
},
|
||||
{
|
||||
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 !== 'de',
|
||||
},
|
||||
]}
|
||||
emptyMessage={t('Keine Einträge')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLanguagesPage;
|
||||
|
|
@ -16,3 +16,4 @@ export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
|||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||
export { AdminLogsPage } from './AdminLogsPage';
|
||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||
|
|
|
|||
|
|
@ -13,14 +13,13 @@ import { useBillingAdmin, type BillingSettings, type AccountSummary, type Mandat
|
|||
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { SubscriptionTab } from './SubscriptionTab';
|
||||
import api from '../../api';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions';
|
||||
type AdminTabType = 'subscription' | 'settings' | 'credit';
|
||||
|
||||
const _formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
|
|
@ -54,9 +53,11 @@ const MandateSelector: React.FC<MandateSelectorProps> = ({
|
|||
loading,
|
||||
selectedMandateId,
|
||||
onSelect,
|
||||
}) => (
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<div className={styles.formGroup}>
|
||||
<label>Mandant auswählen</label>
|
||||
<label>{t('Mandant auswählen')}</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedMandateId || ''}
|
||||
|
|
@ -71,7 +72,8 @@ const MandateSelector: React.FC<MandateSelectorProps> = ({
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SETTINGS EDITOR
|
||||
|
|
@ -84,6 +86,7 @@ interface SettingsEditorProps {
|
|||
}
|
||||
|
||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||
const { t } = useLanguage();
|
||||
const [formData, setFormData] = useState({
|
||||
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||
|
|
@ -123,7 +126,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Billing-Einstellungen</h3>
|
||||
<h3>{t('Billing-Einstellungen')}</h3>
|
||||
|
||||
{message && (
|
||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||
|
|
@ -134,7 +137,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
<form onSubmit={handleSubmit}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Warnschwelle (%)</label>
|
||||
<label>{t('Warnschwelle (%)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
|
|
@ -156,7 +159,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
checked={formData.notifyOnWarning}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, notifyOnWarning: e.target.checked }))}
|
||||
/>
|
||||
Bei Warnung benachrichtigen
|
||||
{t('Bei Warnung benachrichtigen')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -169,7 +172,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
checked={formData.autoRechargeEnabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
|
||||
/>
|
||||
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
|
||||
{t('Auto-Nachladung (KI-Guthaben bei niedrigem Stand)')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,7 +192,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Max. Nachladungen / Monat</label>
|
||||
<label>{t('Max. Nachladungen / Monat')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
|
|
@ -209,7 +212,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={saving || loading}
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
{saving ? t('Speichern…') : t('Einstellungen speichern')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -225,8 +228,9 @@ interface CreditAdderProps {
|
|||
}
|
||||
|
||||
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
||||
const { t } = useLanguage();
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||
const [description, setDescription] = useState<string>(t('Manuelle Buchung durch Admin'));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
|
|
@ -234,7 +238,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
|||
e.preventDefault();
|
||||
const numAmount = parseFloat(amount);
|
||||
if (!numAmount || numAmount === 0) {
|
||||
setMessage({ type: 'error', text: 'Betrag darf nicht null sein' });
|
||||
setMessage({ type: 'error', text: t('Betrag darf nicht null sein') });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -244,12 +248,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
|||
try {
|
||||
await onAddCredit(undefined, numAmount, description);
|
||||
const label = numAmount > 0
|
||||
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||
? t('{betrag} erfolgreich gutgeschrieben.', { betrag: _formatCurrency(numAmount) })
|
||||
: t('{betrag} erfolgreich abgezogen.', { betrag: _formatCurrency(Math.abs(numAmount)) });
|
||||
setMessage({ type: 'success', text: label });
|
||||
setAmount('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' });
|
||||
setMessage({ type: 'error', text: err.message || t('Fehler bei der Buchung') });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -257,7 +261,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
|||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Guthaben manuell verwalten</h3>
|
||||
<h3>{t('Guthaben manuell verwalten')}</h3>
|
||||
|
||||
{message && (
|
||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||
|
|
@ -268,25 +272,25 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
|||
<form onSubmit={_handleSubmit}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Betrag (CHF)</label>
|
||||
<label>{t('Betrag (CHF)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="z.B. 50 oder -20"
|
||||
placeholder={t('z.B. 50 oder -20')}
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Beschreibung</label>
|
||||
<label>{t('Beschreibung')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Beschreibung der Gutschrift"
|
||||
placeholder={t('Beschreibung der Gutschrift')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -296,7 +300,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
|||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={saving || !amount}
|
||||
>
|
||||
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||
{saving ? t('Wird verbucht…') : (parseFloat(amount) < 0 ? t('Guthaben abziehen') : t('Guthaben aufladen'))}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -315,6 +319,7 @@ interface AccountsOverviewProps {
|
|||
}
|
||||
|
||||
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
|
||||
const { t } = useLanguage();
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
|
|
@ -325,27 +330,27 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }
|
|||
const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
||||
return <div className={styles.loadingPlaceholder}>{t('Lade Konten…')}</div>;
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
||||
return <div className={styles.noData}>{t('Keine Konten vorhanden')}</div>;
|
||||
}
|
||||
|
||||
if (poolAccounts.length === 0) {
|
||||
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
|
||||
return <div className={styles.noData}>{t('Kein Mandanten-Konto vorhanden')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Konten</h3>
|
||||
<h3>{t('Konten')}</h3>
|
||||
<div className={styles.accountsGrid}>
|
||||
{poolAccounts.map((account) => (
|
||||
<div key={account.id} className={styles.accountCard}>
|
||||
<h4>Mandanten-Konto</h4>
|
||||
<h4>{t('Mandanten-Konto')}</h4>
|
||||
<div className={styles.accountInfo}>
|
||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||
<span>{t('Guthaben:')} <strong>{formatCurrency(account.balance)}</strong></span>
|
||||
<span>{t('Status:')} {account.enabled ? t('Aktiv') : t('Deaktiviert')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -367,6 +372,7 @@ interface MandateStripeTopUpProps {
|
|||
}
|
||||
|
||||
const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, createCheckout }) => {
|
||||
const { t } = useLanguage();
|
||||
const [amount, setAmount] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localMsg, setLocalMsg] = useState<string | null>(null);
|
||||
|
|
@ -396,7 +402,7 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
window.location.href = result.redirectUrl;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Checkout fehlgeschlagen';
|
||||
const msg = err instanceof Error ? err.message : t('Checkout fehlgeschlagen');
|
||||
setLocalMsg(msg);
|
||||
setBusy(false);
|
||||
}
|
||||
|
|
@ -404,9 +410,9 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Guthaben via Stripe aufladen</h3>
|
||||
<h3>{t('Guthaben via Stripe aufladen')}</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-secondary, #64748b)', marginTop: 0 }}>
|
||||
Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.
|
||||
{t('Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.')}
|
||||
</p>
|
||||
{localMsg && <div className={styles.errorMessage}>{localMsg}</div>}
|
||||
<form onSubmit={_handleSubmit}>
|
||||
|
|
@ -430,106 +436,19 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={busy || !amount}
|
||||
>
|
||||
{busy ? 'Weiterleitung...' : 'Mit Stripe bezahlen'}
|
||||
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export)
|
||||
// ============================================================================
|
||||
|
||||
const _mandateTxColumns: ColumnConfig[] = [
|
||||
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
|
||||
];
|
||||
|
||||
interface MandateTransactionsTabProps {
|
||||
mandateId: string;
|
||||
}
|
||||
|
||||
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
||||
const { request, isLoading: loading } = useApiRequest();
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const _loadTransactions = useCallback(async (params?: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params) {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
const data = await request({
|
||||
url: `/api/billing/admin/transactions/${mandateId}`,
|
||||
method: 'get',
|
||||
params: requestParams,
|
||||
});
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setTransactions(Array.isArray(data) ? data : []);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
||||
setTransactions([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, mandateId]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadTransactions();
|
||||
}, [_loadTransactions]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
||||
AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
|
||||
</p>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
<FormGeneratorTable
|
||||
data={transactions}
|
||||
columns={_mandateTxColumns}
|
||||
apiEndpoint={`/api/billing/admin/transactions/${mandateId}`}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
||||
onRefresh={() => _loadTransactions()}
|
||||
hookData={{ refetch: _loadTransactions, pagination }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export const BillingAdmin: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { user: currentUser } = useCurrentUser();
|
||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
||||
|
|
@ -567,7 +486,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
if (!result) throw new Error('Gutschrift konnte nicht erstellt werden');
|
||||
await loadAccounts();
|
||||
return result;
|
||||
}, [selectedMandateId, addCredit, loadAccounts]);
|
||||
}, [selectedMandateId, addCredit, loadAccounts, t]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -601,9 +520,9 @@ export const BillingAdmin: React.FC = () => {
|
|||
const canceledParam = searchParams.get('canceled');
|
||||
const sessionIdParam = searchParams.get('session_id');
|
||||
|
||||
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings';
|
||||
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'subscription';
|
||||
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
||||
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
||||
['subscription', 'settings', 'credit'].includes(_initialAdminTab) ? _initialAdminTab : 'subscription'
|
||||
);
|
||||
const [subscriptionTabKey, setSubscriptionTabKey] = useState(0);
|
||||
|
||||
|
|
@ -615,7 +534,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
const _confirmCheckoutIfNeeded = async () => {
|
||||
if (successParam !== 'true') {
|
||||
if (canceledParam === 'true' && !cancelled) {
|
||||
setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
||||
setStripeReturnMessage({ type: 'error', text: t('Zahlung abgebrochen.') });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -624,7 +543,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
if (!cancelled) {
|
||||
setStripeReturnMessage({
|
||||
type: 'success',
|
||||
text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.',
|
||||
text: t('Zahlung erfolgreich. Guthaben wird gutgeschrieben.'),
|
||||
});
|
||||
}
|
||||
if (selectedMandateId) await loadAccounts(selectedMandateId);
|
||||
|
|
@ -636,7 +555,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
if (!cancelled) {
|
||||
setStripeReturnMessage({
|
||||
type: 'success',
|
||||
text: 'Zahlung erfolgreich. Guthaben wurde verbucht.',
|
||||
text: t('Zahlung erfolgreich. Guthaben wurde verbucht.'),
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
|
|
@ -646,7 +565,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
type: 'error',
|
||||
text:
|
||||
detail ||
|
||||
'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.',
|
||||
t('Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -659,7 +578,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts, t]);
|
||||
|
||||
const _clearStripeParams = useCallback(() => {
|
||||
searchParams.delete('success');
|
||||
|
|
@ -689,9 +608,9 @@ export const BillingAdmin: React.FC = () => {
|
|||
<header className={styles.pageHeader}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h1>Billing-Verwaltung</h1>
|
||||
<h1>Abrechnung</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant
|
||||
Abonnement, Einstellungen und Guthaben pro Mandant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -707,7 +626,7 @@ export const BillingAdmin: React.FC = () => {
|
|||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
|
||||
<span>{stripeReturnMessage.text}</span>
|
||||
<button type="button" className={styles.button} onClick={_clearStripeParams}>
|
||||
OK
|
||||
{t('OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -731,17 +650,14 @@ export const BillingAdmin: React.FC = () => {
|
|||
borderBottom: '1px solid var(--color-border, #333)',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<button onClick={() => { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
|
||||
{t('Abonnement')}
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
|
||||
Einstellungen
|
||||
{t('Einstellungen')}
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
||||
Guthaben
|
||||
</button>
|
||||
<button onClick={() => { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
|
||||
Abonnement
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
||||
Transaktionen
|
||||
{t('Guthaben aufladen')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
|
|
@ -772,13 +688,9 @@ export const BillingAdmin: React.FC = () => {
|
|||
{adminTab === 'subscription' && (
|
||||
<SubscriptionTab key={subscriptionTabKey} mandateId={selectedMandateId} />
|
||||
)}
|
||||
|
||||
{adminTab === 'transactions' && (
|
||||
<MandateTransactionsTab mandateId={selectedMandateId} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||
<div className={styles.noData}>{t('Bitte wählen Sie einen Mandanten aus.')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,26 @@
|
|||
/**
|
||||
* BillingDataView
|
||||
* BillingDataView (Statistiken)
|
||||
*
|
||||
* Unified billing page with internal tabs:
|
||||
* - Tab "Übersicht": Balance cards + Usage summary for the user
|
||||
* - Tab "Statistik": Dashboard with time-series charts and breakdowns
|
||||
* - Tab "Transaktionen": Transaction table with FormGeneratorTable
|
||||
* Unified statistics page with internal tabs:
|
||||
* - Tab "Übersicht": KPI grid with cost, balance, storage metrics
|
||||
* - Tab "Diagramme": Charts with pie/bar toggle, cost trends
|
||||
* - Tab "Transaktionen": Transaction table with scope filter
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||
import { useBilling } from '../../hooks/useBilling';
|
||||
import { UserTransaction } from '../../api/billingApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER: Currency formatter
|
||||
// ============================================================================
|
||||
|
|
@ -54,69 +57,11 @@ interface DataVolumeInfo {
|
|||
warning: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BALANCE CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface BalanceCardProps {
|
||||
balance: BillingBalance;
|
||||
onOpenMandateAdmin?: (mandateId: string) => void;
|
||||
}
|
||||
|
||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
|
||||
return (
|
||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||
<div className={styles.balanceHeader}>
|
||||
{onOpenMandateAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mandateName}
|
||||
onClick={() => onOpenMandateAdmin(balance.mandateId)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{balance.mandateName}
|
||||
</button>
|
||||
) : (
|
||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.balanceAmount}>
|
||||
{_formatCurrency(balance.balance)}
|
||||
</div>
|
||||
{balance.isWarning && (
|
||||
<div className={styles.warningBadge}>
|
||||
Niedriges Guthaben
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.45,
|
||||
opacity: 0.75,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TAB NAVIGATION COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
type TabType = 'overview' | 'statistics' | 'transactions';
|
||||
type TabType = 'overview' | 'diagrams' | 'transactions';
|
||||
|
||||
interface TabNavProps {
|
||||
activeTab: TabType;
|
||||
|
|
@ -124,6 +69,7 @@ interface TabNavProps {
|
|||
}
|
||||
|
||||
const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const _navLinkStyle = (isActive: boolean) => ({
|
||||
padding: '8px 16px',
|
||||
textDecoration: 'none',
|
||||
|
|
@ -145,13 +91,13 @@ const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
|
|||
paddingBottom: '8px'
|
||||
}}>
|
||||
<button onClick={() => onTabChange('overview')} style={_navLinkStyle(activeTab === 'overview')}>
|
||||
Übersicht
|
||||
{t('Übersicht')}
|
||||
</button>
|
||||
<button onClick={() => onTabChange('statistics')} style={_navLinkStyle(activeTab === 'statistics')}>
|
||||
Statistik
|
||||
<button onClick={() => onTabChange('diagrams')} style={_navLinkStyle(activeTab === 'diagrams')}>
|
||||
{t('Diagramme')}
|
||||
</button>
|
||||
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
|
||||
Transaktionen
|
||||
{t('Transaktionen')}
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
|
|
@ -161,13 +107,18 @@ const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
|
|||
// HELPERS: Convert viewStats to ReportSection arrays
|
||||
// ============================================================================
|
||||
|
||||
function _recordToChartData(record: Record<string, number>): ReportChartDataPoint[] {
|
||||
function _recordToChartData(record: Record<string, number>, tr: TranslateFn): ReportChartDataPoint[] {
|
||||
return Object.entries(record)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([key, value]) => ({ key: key || '—', value }));
|
||||
.map(([key, value]) => ({ key: key || tr('—'), value }));
|
||||
}
|
||||
|
||||
function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
|
||||
function _buildOverviewSections(
|
||||
viewStats: ViewStatistics,
|
||||
totalBalance: number,
|
||||
totalStorageMB: number,
|
||||
tr: TranslateFn,
|
||||
): ReportSection[] {
|
||||
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
|
||||
const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0];
|
||||
const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0];
|
||||
|
|
@ -177,53 +128,39 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
|
|||
type: 'kpiGrid',
|
||||
items: [
|
||||
{
|
||||
label: 'Gesamtkosten',
|
||||
label: tr('Gesamtkosten'),
|
||||
value: _formatCurrency(viewStats.totalCost),
|
||||
subtitle: `${viewStats.transactionCount} Transaktionen`
|
||||
subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }),
|
||||
},
|
||||
{
|
||||
label: 'Anbieter',
|
||||
label: tr('Anbieter'),
|
||||
value: Object.keys(viewStats.costByProvider).length,
|
||||
subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung'
|
||||
subtitle: topProvider ? tr('Top: {name}', { name: topProvider[0] }) : tr('Keine Nutzung'),
|
||||
},
|
||||
{
|
||||
label: 'Modelle',
|
||||
label: tr('Modelle'),
|
||||
value: Object.keys(viewStats.costByModel || {}).length,
|
||||
subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung'
|
||||
subtitle: topModel ? tr('Top: {name}', { name: topModel[0] }) : tr('Keine Nutzung'),
|
||||
},
|
||||
{
|
||||
label: 'Features',
|
||||
label: tr('Features'),
|
||||
value: Object.keys(viewStats.costByFeature).length,
|
||||
subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung'
|
||||
}
|
||||
subtitle: topFeature ? tr('Top: {name}', { name: topFeature[0] }) : tr('Keine Nutzung'),
|
||||
},
|
||||
{
|
||||
label: tr('Guthaben'),
|
||||
value: _formatCurrency(totalBalance),
|
||||
},
|
||||
{
|
||||
label: tr('Speicher'),
|
||||
value: formatBinaryDataSizeFromMebibytes(totalStorageMB),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'horizontalBar',
|
||||
title: 'Kosten nach Anbieter',
|
||||
data: _recordToChartData(viewStats.costByProvider),
|
||||
formatValue: _formatCurrency,
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'horizontalBar',
|
||||
title: 'Kosten nach Modell',
|
||||
data: _recordToChartData(viewStats.costByModel || {}),
|
||||
formatValue: _formatCurrency,
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'horizontalBar',
|
||||
title: 'Kosten nach Feature',
|
||||
data: _recordToChartData(viewStats.costByFeature),
|
||||
formatValue: _formatCurrency,
|
||||
span: 'half' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
||||
// Convert timeSeries to barChart data
|
||||
function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'bar', tr: TranslateFn): ReportSection[] {
|
||||
const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({
|
||||
key: ts.date,
|
||||
value: ts.cost
|
||||
|
|
@ -233,63 +170,59 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
|||
? viewStats.totalCost / viewStats.transactionCount
|
||||
: 0;
|
||||
|
||||
const chartType = chartMode === 'pie' ? 'pieChart' : 'horizontalBar';
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'kpiGrid',
|
||||
items: [
|
||||
{ label: tr('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }) },
|
||||
{ label: tr('Durchschnitt'), value: _formatCurrency(avgCost), subtitle: tr('pro Transaktion') },
|
||||
{ label: tr('Anbieter'), value: Object.keys(viewStats.costByProvider).length },
|
||||
{ label: tr('Modelle'), value: Object.keys(viewStats.costByModel || {}).length },
|
||||
{ label: tr('Features'), value: Object.keys(viewStats.costByFeature).length },
|
||||
{ label: tr('Mandanten'), value: Object.keys(viewStats.costByMandate).length },
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'barChart',
|
||||
title: 'Kostenentwicklung',
|
||||
title: tr('Kostenentwicklung'),
|
||||
data: timeSeriesData,
|
||||
formatValue: _formatCurrency,
|
||||
span: 'full' as const
|
||||
},
|
||||
{
|
||||
type: 'pieChart',
|
||||
title: 'Verteilung nach Anbieter',
|
||||
data: _recordToChartData(viewStats.costByProvider),
|
||||
type: chartType,
|
||||
title: tr('Kosten nach Anbieter'),
|
||||
data: _recordToChartData(viewStats.costByProvider, tr),
|
||||
formatValue: _formatCurrency,
|
||||
donut: true,
|
||||
donut: chartMode === 'pie',
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'pieChart',
|
||||
title: 'Verteilung nach Modell',
|
||||
data: _recordToChartData(viewStats.costByModel || {}),
|
||||
type: chartType,
|
||||
title: tr('Kosten nach Modell'),
|
||||
data: _recordToChartData(viewStats.costByModel || {}, tr),
|
||||
formatValue: _formatCurrency,
|
||||
donut: true,
|
||||
donut: chartMode === 'pie',
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'pieChart',
|
||||
title: 'Verteilung nach Feature',
|
||||
data: _recordToChartData(viewStats.costByFeature),
|
||||
type: chartType,
|
||||
title: tr('Kosten nach Feature'),
|
||||
data: _recordToChartData(viewStats.costByFeature, tr),
|
||||
formatValue: _formatCurrency,
|
||||
donut: true,
|
||||
donut: chartMode === 'pie',
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'horizontalBar',
|
||||
title: 'Kosten nach Mandant',
|
||||
data: _recordToChartData(viewStats.costByMandate),
|
||||
type: chartType,
|
||||
title: tr('Kosten nach Mandant'),
|
||||
data: _recordToChartData(viewStats.costByMandate, tr),
|
||||
formatValue: _formatCurrency,
|
||||
donut: chartMode === 'pie',
|
||||
span: 'half' as const
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
title: 'Zusammenfassung',
|
||||
span: 'half' as const,
|
||||
columns: [
|
||||
{ key: 'metric', label: 'Kennzahl' },
|
||||
{ key: 'value', label: 'Wert', align: 'right' as const }
|
||||
],
|
||||
rows: [
|
||||
{ metric: 'Gesamtkosten', value: _formatCurrency(viewStats.totalCost) },
|
||||
{ metric: 'Transaktionen', value: String(viewStats.transactionCount) },
|
||||
{ metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) },
|
||||
{ metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) },
|
||||
{ metric: 'Modelle', value: String(Object.keys(viewStats.costByModel || {}).length) },
|
||||
{ metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) },
|
||||
{ metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -298,14 +231,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
|||
// ============================================================================
|
||||
|
||||
export const BillingDataView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
|
||||
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
|
||||
}, [navigate]);
|
||||
const [chartMode, setChartMode] = useState<'pie' | 'bar'>('pie');
|
||||
const [onlyMyData, setOnlyMyData] = useState(false);
|
||||
|
||||
// Scope filter: 'personal' | 'all' | mandateId
|
||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||
|
|
@ -327,14 +258,14 @@ export const BillingDataView: React.FC = () => {
|
|||
const _confirmCheckoutIfNeeded = async () => {
|
||||
if (successParam !== 'true') {
|
||||
if (canceledParam === 'true' && !cancelled) {
|
||||
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
|
||||
setCheckoutMessage({ type: 'error', text: t('Zahlung abgebrochen.') });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionIdParam) {
|
||||
if (!cancelled) {
|
||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
|
||||
setCheckoutMessage({ type: 'success', text: t('Zahlung erfolgreich. Guthaben wird gutgeschrieben.') });
|
||||
}
|
||||
refetchBalances();
|
||||
return;
|
||||
|
|
@ -350,7 +281,7 @@ export const BillingDataView: React.FC = () => {
|
|||
if (!cancelled) {
|
||||
setCheckoutMessage({
|
||||
type: 'error',
|
||||
text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.'
|
||||
text: detail || t('Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.')
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -379,7 +310,7 @@ export const BillingDataView: React.FC = () => {
|
|||
|
||||
// Storage volume state (for Statistics tab)
|
||||
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
|
||||
const [storageLoading, setStorageLoading] = useState(false);
|
||||
const [, setStorageLoading] = useState(false);
|
||||
|
||||
// Transactions state (for Transactions tab)
|
||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||
|
|
@ -387,23 +318,22 @@ export const BillingDataView: React.FC = () => {
|
|||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
|
||||
// Unified scope params -- single source of truth for all tab API calls
|
||||
const _scopeParams = useMemo((): Record<string, string> => {
|
||||
if (onlyMyData) return { scope: 'personal' };
|
||||
if (selectedScope === 'personal') return { scope: 'personal' };
|
||||
if (selectedScope === 'all') return { scope: 'all' };
|
||||
return { scope: 'mandate', mandateId: selectedScope };
|
||||
}, [selectedScope, onlyMyData]);
|
||||
|
||||
// Load aggregated statistics from the view/statistics route
|
||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
||||
try {
|
||||
setStatsLoading(true);
|
||||
const params: any = { period, year };
|
||||
const params: Record<string, string | number> = { period, year, ..._scopeParams };
|
||||
if (period === 'day' && month) {
|
||||
params.month = month;
|
||||
}
|
||||
// Apply scope filter
|
||||
if (selectedScope === 'personal') {
|
||||
params.scope = 'personal';
|
||||
} else if (selectedScope !== 'all') {
|
||||
params.scope = 'mandate';
|
||||
params.mandateId = selectedScope;
|
||||
} else {
|
||||
params.scope = 'all';
|
||||
}
|
||||
const response = await api.get('/api/billing/view/statistics', { params });
|
||||
setViewStats(response.data);
|
||||
} catch (err: any) {
|
||||
|
|
@ -412,7 +342,7 @@ export const BillingDataView: React.FC = () => {
|
|||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [selectedScope]);
|
||||
}, [_scopeParams]);
|
||||
|
||||
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
|
||||
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||
|
|
@ -458,19 +388,19 @@ export const BillingDataView: React.FC = () => {
|
|||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview' || activeTab === 'statistics') {
|
||||
if (activeTab === 'overview' || activeTab === 'diagrams') {
|
||||
_loadViewStatistics('month', new Date().getFullYear());
|
||||
_loadStorageData();
|
||||
}
|
||||
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
|
||||
}, [activeTab, _loadViewStatistics, _loadStorageData]);
|
||||
|
||||
// Load transactions with pagination support
|
||||
// Load transactions with pagination support + scope filter
|
||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||
try {
|
||||
setTransactionsLoading(true);
|
||||
setTransactionsError(null);
|
||||
|
||||
const params: any = {};
|
||||
const params: Record<string, string> = { ..._scopeParams };
|
||||
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
|
||||
const pObj: any = {};
|
||||
if (paginationParams.page !== undefined) pObj.page = paginationParams.page;
|
||||
|
|
@ -486,60 +416,76 @@ export const BillingDataView: React.FC = () => {
|
|||
const response = await api.get('/api/billing/view/users/transactions', { params });
|
||||
const data = response.data;
|
||||
|
||||
// Handle PaginatedResponse format: { items: [...], pagination: {...} }
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||
if (data.pagination) {
|
||||
setTransactionsPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
// Backward compatibility: plain array response
|
||||
setTransactions(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load transactions:', err);
|
||||
setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen');
|
||||
setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen'));
|
||||
} finally {
|
||||
setTransactionsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [_scopeParams, t]);
|
||||
|
||||
// Load transactions when switching to transactions tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'transactions') {
|
||||
_loadTransactions();
|
||||
const _fetchTransactionFilterValues = useCallback(async (
|
||||
columnKey: string,
|
||||
crossFilters?: Record<string, any>,
|
||||
): Promise<string[]> => {
|
||||
const params: Record<string, string> = {
|
||||
column: columnKey,
|
||||
..._scopeParams,
|
||||
};
|
||||
if (crossFilters && Object.keys(crossFilters).length > 0) {
|
||||
params.pagination = JSON.stringify({ filters: crossFilters });
|
||||
}
|
||||
}, [activeTab, _loadTransactions]);
|
||||
const resp = await api.get('/api/billing/view/users/transactions/filter-values', { params });
|
||||
return Array.isArray(resp.data) ? resp.data : [];
|
||||
}, [_scopeParams]);
|
||||
|
||||
// hookData for FormGeneratorTable
|
||||
const transactionsHookData = useMemo(() => ({
|
||||
refetch: _loadTransactions,
|
||||
pagination: transactionsPagination || undefined,
|
||||
}), [_loadTransactions, transactionsPagination]);
|
||||
fetchFilterValues: _fetchTransactionFilterValues,
|
||||
}), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]);
|
||||
|
||||
// Table column definitions
|
||||
const columns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
|
||||
], []);
|
||||
{ key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, width: 120 },
|
||||
], [t]);
|
||||
|
||||
const totalBalance = useMemo(() => {
|
||||
const filtered = selectedScope === 'personal' || selectedScope === 'all'
|
||||
? balances
|
||||
: balances.filter(b => b.mandateId === selectedScope);
|
||||
return filtered.reduce((sum, b) => sum + (b.balance || 0), 0);
|
||||
}, [balances, selectedScope]);
|
||||
|
||||
const totalStorageMB = useMemo(() => {
|
||||
return storageData.reduce((sum, s) => sum + (s.usedMB || 0), 0);
|
||||
}, [storageData]);
|
||||
|
||||
// Build report sections based on current data
|
||||
const overviewSections = useMemo<ReportSection[]>(() => {
|
||||
if (!viewStats) return [];
|
||||
return _buildOverviewSections(viewStats);
|
||||
}, [viewStats]);
|
||||
return _buildOverviewSections(viewStats, totalBalance, totalStorageMB, t);
|
||||
}, [viewStats, totalBalance, totalStorageMB, t]);
|
||||
|
||||
const statisticsSections = useMemo<ReportSection[]>(() => {
|
||||
const diagramSections = useMemo<ReportSection[]>(() => {
|
||||
if (!viewStats) return [];
|
||||
return _buildStatisticsSections(viewStats);
|
||||
}, [viewStats]);
|
||||
return _buildDiagramSections(viewStats, chartMode, t);
|
||||
}, [viewStats, chartMode, t]);
|
||||
|
||||
// Period selector config (shared between overview and statistics)
|
||||
const periodSelectorConfig = useMemo(() => ({
|
||||
|
|
@ -554,31 +500,31 @@ export const BillingDataView: React.FC = () => {
|
|||
// Build scope options from balances (mandates the user has access to)
|
||||
const scopeOptions = useMemo(() => {
|
||||
const options: Array<{ value: string; label: string }> = [
|
||||
{ value: 'personal', label: 'Meine Kosten' },
|
||||
{ value: 'personal', label: t('Meine Kosten') },
|
||||
];
|
||||
// Add mandate options from balances
|
||||
const seen = new Set<string>();
|
||||
for (const b of balances) {
|
||||
if (!seen.has(b.mandateId)) {
|
||||
seen.add(b.mandateId);
|
||||
options.push({ value: b.mandateId, label: `Mandant: ${b.mandateName}` });
|
||||
options.push({ value: b.mandateId, label: t('Mandant: {name}', { name: b.mandateName }) });
|
||||
}
|
||||
}
|
||||
options.push({ value: 'all', label: 'Alle (RBAC)' });
|
||||
options.push({ value: 'all', label: t('Alle (RBAC)') });
|
||||
return options;
|
||||
}, [balances]);
|
||||
}, [balances, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
<header className={styles.pageHeader}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1>Billing</h1>
|
||||
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
|
||||
<h1>Statistiken</h1>
|
||||
<p className={styles.subtitle}>Nutzung, Diagramme und Transaktionen</p>
|
||||
</div>
|
||||
{activeTab !== 'transactions' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<label style={{ fontSize: '13px', opacity: 0.7 }}>Ansicht:</label>
|
||||
<label style={{ fontSize: '13px', opacity: 0.7 }}>{t('Kontext:')}</label>
|
||||
<select
|
||||
className={styles.select || ''}
|
||||
value={selectedScope}
|
||||
|
|
@ -589,7 +535,16 @@ export const BillingDataView: React.FC = () => {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyMyData}
|
||||
onChange={(e) => setOnlyMyData(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
{t('nur meine Daten')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -599,208 +554,76 @@ export const BillingDataView: React.FC = () => {
|
|||
<div className={checkoutMessage.type === 'success' ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
||||
{checkoutMessage.text}
|
||||
{(successParam || canceledParam) && (
|
||||
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>Schliessen</button>
|
||||
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>{t('Schließen')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Übersicht (My Overview) */}
|
||||
{/* Tab: Übersicht (KPI overview) */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'overview' && (() => {
|
||||
// Filter balances and user accounts by scope
|
||||
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
|
||||
? balances
|
||||
: balances.filter(b => b.mandateId === selectedScope);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Balance Cards - own balances */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
|
||||
{dashboardLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
|
||||
) : filteredBalances.length === 0 ? (
|
||||
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
|
||||
) : (
|
||||
<div className={styles.balanceGrid}>
|
||||
{filteredBalances.map((balance) => (
|
||||
<BalanceCard
|
||||
key={balance.mandateId}
|
||||
balance={balance}
|
||||
onOpenMandateAdmin={_openMandateBillingAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Storage quick info */}
|
||||
{!storageLoading && storageData.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Speicher</h2>
|
||||
<div className={styles.balanceGrid}>
|
||||
{storageData.map((sv) => {
|
||||
const pct = sv.percentUsed ?? 0;
|
||||
const barColor = pct >= 90
|
||||
? 'var(--color-error, #ef4444)'
|
||||
: pct >= 70
|
||||
? 'var(--color-warning, #f59e0b)'
|
||||
: 'var(--primary-color, #F25843)';
|
||||
return (
|
||||
<div key={sv.mandateId} className={styles.balanceCard}>
|
||||
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
|
||||
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
|
||||
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
|
||||
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
|
||||
</span>
|
||||
</div>
|
||||
{sv.maxDataVolumeMB != null && (
|
||||
<div style={{
|
||||
height: '6px',
|
||||
background: 'var(--bg-secondary, #2a2a2a)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
marginTop: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
background: barColor,
|
||||
borderRadius: '3px',
|
||||
minWidth: pct > 0 ? '3px' : '0',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{sv.warning && (
|
||||
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
|
||||
Speicher knapp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Usage Statistics via FormGeneratorReport */}
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
title={selectedScope === 'personal' ? 'Meine Nutzung' : 'Nutzungsübersicht'}
|
||||
loading={statsLoading}
|
||||
sections={overviewSections}
|
||||
noDataMessage="Keine Statistiken verfügbar"
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{activeTab === 'overview' && (
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
loading={statsLoading || dashboardLoading}
|
||||
sections={overviewSections}
|
||||
noDataMessage={t('Keine Statistiken verfügbar')}
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ================================================================ */}
|
||||
{/* Tab: Statistik (Dashboard) */}
|
||||
{/* Tab: Diagramme */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'statistics' && (
|
||||
<>
|
||||
{/* Storage volume section */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.statisticsChart}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Speicherverbrauch
|
||||
</h3>
|
||||
{storageLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||
) : storageData.length === 0 ? (
|
||||
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{storageData.map((sv) => {
|
||||
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
|
||||
const maxLabel = sv.maxDataVolumeMB != null
|
||||
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
|
||||
: 'unbegrenzt';
|
||||
const pct = sv.percentUsed ?? 0;
|
||||
const barColor = pct >= 90
|
||||
? 'var(--color-error, #ef4444)'
|
||||
: pct >= 70
|
||||
? 'var(--color-warning, #f59e0b)'
|
||||
: 'var(--primary-color, #F25843)';
|
||||
|
||||
return (
|
||||
<div key={sv.mandateId} style={{
|
||||
background: 'var(--bg-secondary, #2a2a2a)',
|
||||
borderRadius: '8px',
|
||||
padding: '14px 16px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{sv.mandateName}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
|
||||
{usedLabel} / {maxLabel}
|
||||
{sv.percentUsed != null && (
|
||||
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
|
||||
({pct.toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{sv.maxDataVolumeMB != null && (
|
||||
<div style={{
|
||||
height: '10px',
|
||||
background: 'var(--surface-color, #1e1e1e)',
|
||||
borderRadius: '5px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
background: barColor,
|
||||
borderRadius: '5px',
|
||||
transition: 'width 0.4s ease',
|
||||
minWidth: pct > 0 ? '4px' : '0',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--text-secondary, #888)',
|
||||
}}>
|
||||
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
|
||||
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'diagrams' && (
|
||||
<section className={styles.section}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setChartMode('pie')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '13px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: chartMode === 'pie' ? 'var(--primary-color, #F25843)' : 'var(--bg-secondary, #2a2a2a)',
|
||||
color: chartMode === 'pie' ? '#fff' : 'var(--color-text, #e0e0e0)',
|
||||
fontWeight: chartMode === 'pie' ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{t('Pie')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChartMode('bar')}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '13px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: chartMode === 'bar' ? 'var(--primary-color, #F25843)' : 'var(--bg-secondary, #2a2a2a)',
|
||||
color: chartMode === 'bar' ? '#fff' : 'var(--color-text, #e0e0e0)',
|
||||
fontWeight: chartMode === 'bar' ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{t('Balken')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI usage statistics */}
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
title="Nutzungsstatistik"
|
||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||
periodSelector={periodSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={statisticsSections}
|
||||
noDataMessage="Keine Statistiken verfügbar"
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
<FormGeneratorReport
|
||||
periodSelector={periodSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={diagramSections}
|
||||
noDataMessage={t('Keine Statistiken verfügbar')}
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ================================================================ */}
|
||||
|
|
@ -815,6 +638,7 @@ export const BillingDataView: React.FC = () => {
|
|||
)}
|
||||
|
||||
<FormGeneratorTable
|
||||
key={`txn-${_scopeParams.scope}-${_scopeParams.mandateId ?? ''}-${onlyMyData}`}
|
||||
data={transactions}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/billing/view/users/transactions"
|
||||
|
|
@ -825,7 +649,7 @@ export const BillingDataView: React.FC = () => {
|
|||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
emptyMessage="Keine Transaktionen vorhanden"
|
||||
emptyMessage={t('Keine Transaktionen vorhanden')}
|
||||
onRefresh={_loadTransactions}
|
||||
hookData={transactionsHookData}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
|||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../../hooks/useConfirm';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import {
|
||||
fetchAccountingConnectors,
|
||||
fetchAccountingConfig,
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { request } = useApiRequest();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
|
@ -40,6 +42,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const [importDone, setImportDone] = useState(false);
|
||||
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
||||
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
|
|
@ -47,8 +50,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (!importDone) return;
|
||||
const t = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
||||
return () => clearTimeout(t);
|
||||
const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
||||
return () => clearTimeout(importResetTimer);
|
||||
}, [importDone]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
|
|
@ -115,10 +118,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
displayLabel,
|
||||
config: configValues,
|
||||
});
|
||||
showSuccess('Saved', 'Accounting configuration saved successfully.');
|
||||
showSuccess(t('Gespeichert'), t('Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.'));
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
showError('Error', err.response?.data?.detail || err.message || 'Failed to save configuration.');
|
||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Speichern der Konfiguration fehlgeschlagen.'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -132,14 +135,14 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const result = await testAccountingConnection(request, instanceId);
|
||||
setTestResult({ success: result.success, message: result.errorMessage });
|
||||
if (result.success) {
|
||||
showSuccess('Connection OK', 'Successfully connected to the accounting system.');
|
||||
showSuccess(t('Verbindung OK'), t('Verbindung zum Buchhaltungssystem erfolgreich.'));
|
||||
} else {
|
||||
showError('Connection Failed', result.errorMessage || 'Could not connect.');
|
||||
showError(t('Verbindung fehlgeschlagen'), result.errorMessage || t('Keine Verbindung möglich.'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.detail || err.message || 'Connection test failed.';
|
||||
const msg = err.response?.data?.detail || err.message || t('Verbindungstest fehlgeschlagen.');
|
||||
setTestResult({ success: false, message: msg });
|
||||
showError('Error', msg);
|
||||
showError(t('Fehler'), msg);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
|
@ -147,23 +150,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
|
||||
const handleRemove = async () => {
|
||||
if (!instanceId) return;
|
||||
const ok = await confirm('Remove the accounting integration? This does not delete synced data.', {
|
||||
title: 'Remove Integration',
|
||||
confirmLabel: 'Remove',
|
||||
const ok = await confirm(t('Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.'), {
|
||||
title: t('Anbindung entfernen'),
|
||||
confirmLabel: t('Entfernen'),
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!ok) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteAccountingConfig(request, instanceId);
|
||||
showSuccess('Removed', 'Accounting integration removed.');
|
||||
showSuccess(t('Entfernt'), t('Buchhaltungsanbindung wurde entfernt.'));
|
||||
setSelectedType('');
|
||||
setDisplayLabel('');
|
||||
setConfigValues({});
|
||||
setTestResult(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
showError('Error', err.message || 'Failed to remove configuration.');
|
||||
showError(t('Fehler'), err.message || t('Entfernen der Konfiguration fehlgeschlagen.'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -172,22 +175,22 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const selectedConnector = _getSelectedConnector();
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading accounting settings...</div>;
|
||||
return <div className={styles.loading}>{t('Buchhaltungseinstellungen werden geladen…')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
<h3 className={styles.sectionTitle}>Accounting System Integration</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('Buchhaltungssystem-Anbindung')}</h3>
|
||||
<p className={styles.sectionDescription}>
|
||||
Connect an accounting system to automatically sync bookings from this Trustee instance.
|
||||
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
|
||||
</p>
|
||||
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>Connected:</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||
<> · Last sync: {existingConfig.lastSyncStatus}</>
|
||||
<> · {t('Letzter Sync:')} {existingConfig.lastSyncStatus}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -200,10 +203,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{existingConfig.lastSyncAt != null && (
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>Letzter Sync:</strong>{' '}
|
||||
<strong>{t('Letzter Sync:')}</strong>{' '}
|
||||
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||||
<> · Status: {existingConfig.lastSyncStatus}</>
|
||||
<> · {t('Status:')} {existingConfig.lastSyncStatus}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -226,13 +229,13 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber}>1</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4>Accounting System</h4>
|
||||
<h4>{t('Buchhaltungssystem')}</h4>
|
||||
<select
|
||||
className={styles.folderSelect}
|
||||
value={selectedType}
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select a system...</option>
|
||||
<option value="">{t('System auswählen…')}</option>
|
||||
{connectors.map(c => (
|
||||
<option key={c.connectorType} value={c.connectorType}>
|
||||
{c.label?.de || c.label?.en || c.connectorType}
|
||||
|
|
@ -251,14 +254,14 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
||||
Display Label
|
||||
{t('Anzeigename')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.folderSelect}
|
||||
value={displayLabel}
|
||||
onChange={e => setDisplayLabel(e.target.value)}
|
||||
placeholder="e.g. Run My Accounts - Muster AG"
|
||||
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
||||
/>
|
||||
</div>
|
||||
{selectedConnector.configFields.map(field => (
|
||||
|
|
@ -290,7 +293,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<h4>Save & Test</h4>
|
||||
{testResult && (
|
||||
<div className={testResult.success ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '0.75rem' }}>
|
||||
{testResult.success ? 'Connection successful!' : `Connection failed: ${testResult.message || 'Unknown error'}`}
|
||||
{testResult.success ? t('Verbindung erfolgreich!') : t('Verbindung fehlgeschlagen: {message}', { message: testResult.message || t('Unbekannter Fehler') })}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
|
|
@ -299,7 +302,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
{saving ? t('Speichern…') : t('Konfiguration speichern')}
|
||||
</button>
|
||||
{existingConfig?.configured && (
|
||||
<button
|
||||
|
|
@ -307,7 +310,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
onClick={handleTestConnection}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
{testing ? t('Teste…') : t('Verbindung testen')}
|
||||
</button>
|
||||
)}
|
||||
{existingConfig?.configured && (
|
||||
|
|
@ -317,7 +320,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
disabled={saving}
|
||||
style={{ color: 'var(--error-color, #dc2626)' }}
|
||||
>
|
||||
Remove Integration
|
||||
{t('Anbindung entfernen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -330,10 +333,9 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber}>4</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4>Buchhaltungsdaten importieren</h4>
|
||||
<h4>{t('Buchhaltungsdaten importieren')}</h4>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
|
||||
Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen.
|
||||
Diese Daten stehen anschliessend im AI Workspace fuer Analysen zur Verfuegung.
|
||||
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
|
|
@ -342,20 +344,20 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>Bis (optional)</label>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Bis (optional)')}</label>
|
||||
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'YTD', from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
|
||||
{ label: t('Laufendes Jahr'), from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
|
||||
{
|
||||
label: 'Letztes Jahr',
|
||||
label: t('Letztes Jahr'),
|
||||
from: `${new Date().getFullYear() - 1}-01-01`,
|
||||
to: `${new Date().getFullYear() - 1}-12-31`,
|
||||
},
|
||||
{
|
||||
label: 'Letzter Monat',
|
||||
label: t('Letzter Monat'),
|
||||
from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
|
||||
to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
|
||||
},
|
||||
|
|
@ -372,55 +374,88 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
disabled={importing}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
if (dateFrom) body.dateFrom = dateFrom;
|
||||
if (dateTo) body.dateTo = dateTo;
|
||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||
if (mountedRef.current) {
|
||||
setImportResult(res.data);
|
||||
if (res.data.errors?.length) {
|
||||
showError('Import teilweise fehlgeschlagen', res.data.errors.join('; '));
|
||||
} else {
|
||||
showSuccess('Import abgeschlossen',
|
||||
`${res.data.accounts || 0} Konten, ${res.data.journalEntries || 0} Buchungen, ` +
|
||||
`${res.data.contacts || 0} Kontakte, ${res.data.accountBalances || 0} Salden importiert.`);
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
disabled={importing}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
if (dateFrom) body.dateFrom = dateFrom;
|
||||
if (dateTo) body.dateTo = dateTo;
|
||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||
if (mountedRef.current) {
|
||||
setImportResult(res.data);
|
||||
if (res.data.errors?.length) {
|
||||
showError(t('Import teilweise fehlgeschlagen'), res.data.errors.join('; '));
|
||||
} else {
|
||||
showSuccess(t('Import abgeschlossen'),
|
||||
t('{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.', {
|
||||
konten: String(res.data.accounts || 0),
|
||||
buchungen: String(res.data.journalEntries || 0),
|
||||
kontakte: String(res.data.contacts || 0),
|
||||
salden: String(res.data.accountBalances || 0),
|
||||
}));
|
||||
}
|
||||
_loadImportStatus();
|
||||
}
|
||||
_loadImportStatus();
|
||||
} catch (err: any) {
|
||||
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
||||
} finally {
|
||||
setImportDone(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError('Import fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setImportDone(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{importing ? 'Importiere...' : 'Daten jetzt einlesen'}
|
||||
</button>
|
||||
}}
|
||||
>
|
||||
{importing ? t('Importiere…') : t('Daten jetzt einlesen')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
disabled={clearingCache}
|
||||
onClick={async () => {
|
||||
if (!instanceId) return;
|
||||
setClearingCache(true);
|
||||
try {
|
||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
||||
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(res.data?.cleared ?? 0) }));
|
||||
} catch (err: any) {
|
||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{clearingCache ? t('Leere…') : t('KI-Cache leeren')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{importResult && !importResult.errors?.length && (
|
||||
<div className={styles.successMessage} style={{ marginTop: '0.75rem' }}>
|
||||
Import abgeschlossen in {importResult.durationSeconds}s:
|
||||
{' '}{importResult.accounts} Konten, {importResult.journalEntries} Buchungen ({importResult.journalLines} Zeilen),
|
||||
{' '}{importResult.contacts} Kontakte, {importResult.accountBalances} Salden
|
||||
{t('Import abgeschlossen in {sek}s:', { sek: String(importResult.durationSeconds) })}{' '}
|
||||
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', {
|
||||
konten: String(importResult.accounts),
|
||||
buchungen: String(importResult.journalEntries),
|
||||
zeilen: String(importResult.journalLines),
|
||||
kontakte: String(importResult.contacts),
|
||||
salden: String(importResult.accountBalances),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
<strong>Aktueller Datenbestand:</strong>{' '}
|
||||
{importStatus.accounts} Konten, {importStatus.journalEntries} Buchungen,
|
||||
{' '}{importStatus.journalLines} Zeilen, {importStatus.contacts} Kontakte,
|
||||
{' '}{importStatus.accountBalances} Salden
|
||||
<strong>{t('Aktueller Datenbestand:')}</strong>{' '}
|
||||
{t('{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden', {
|
||||
konten: String(importStatus.accounts),
|
||||
buchungen: String(importStatus.journalEntries),
|
||||
zeilen: String(importStatus.journalLines),
|
||||
kontakte: String(importStatus.contacts),
|
||||
salden: String(importStatus.accountBalances),
|
||||
})}
|
||||
{importStatus.lastSyncAt && (
|
||||
<> · Letzter Import: {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
||||
<> · {t('Letzter Import:')} {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
* Setup page for automatic expense import from SharePoint PDFs.
|
||||
* Allows users to connect their Microsoft account, select a SharePoint folder,
|
||||
* and activate daily automation for expense extraction.
|
||||
*
|
||||
* Uses the consolidated workflow engine via /api/workflows/{instanceId}/.
|
||||
* The routes accept any feature instanceId the user has access to (not limited
|
||||
* to graphicalEditor instances).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
|
@ -11,9 +15,9 @@ import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
|||
import { useConnections } from '../../../hooks/useConnections';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
import { _buildExpenseImportGraph, _buildScheduledExpenseImportGraph } from './trusteePipelineGraph';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
// Default extraction prompt (from automation template)
|
||||
const DEFAULT_EXTRACTION_PROMPT = `Du bist ein Spezialist für die Extraktion von Spesendaten aus PDF-Dokumenten und deren buchhalterische Kontierung.
|
||||
|
||||
AUFGABE:
|
||||
|
|
@ -64,6 +68,9 @@ HINWEISE:
|
|||
- Wenn mehrere MwSt-Sätze vorhanden sind, separate Datensätze erstellen
|
||||
- Bei fehlenden Informationen: leeres Feld oder Standardwert`;
|
||||
|
||||
const EXPENSE_IMPORT_LABEL = 'Trustee Expense Import';
|
||||
const DAILY_CRON = '0 22 * * *';
|
||||
|
||||
interface SiteOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
|
@ -87,24 +94,22 @@ interface Connection {
|
|||
authority: string;
|
||||
status: string;
|
||||
externalUsername?: string;
|
||||
accountName?: string; // Legacy fallback
|
||||
connectionReference?: string; // Backend computed field: connection:{authority}:{username}
|
||||
displayLabel?: string; // Backend computed field: human-readable label
|
||||
accountName?: string;
|
||||
connectionReference?: string;
|
||||
displayLabel?: string;
|
||||
}
|
||||
|
||||
interface ExistingAutomation {
|
||||
interface ExistingWorkflow {
|
||||
id: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
schedule: string;
|
||||
placeholders: Record<string, string>;
|
||||
connectionReference: string;
|
||||
sharepointFolder: string;
|
||||
}
|
||||
|
||||
// Helper function to safely convert error detail to string
|
||||
const parseErrorDetail = (detail: any): string => {
|
||||
const _parseErrorDetail = (detail: any): string => {
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
// FastAPI validation errors come as array of {type, loc, msg, input}
|
||||
return detail.map(e => e.msg || JSON.stringify(e)).join(', ');
|
||||
}
|
||||
if (typeof detail === 'object' && detail !== null) {
|
||||
|
|
@ -113,8 +118,18 @@ const parseErrorDetail = (detail: any): string => {
|
|||
return String(detail);
|
||||
};
|
||||
|
||||
function _extractWorkflowConfig(workflow: any): { connectionReference: string; sharepointFolder: string } {
|
||||
const nodes = workflow?.graph?.nodes || [];
|
||||
const extractNode = nodes.find((n: any) =>
|
||||
n.type === 'trustee.extractFromFiles' || n._action === 'extractFromFiles'
|
||||
);
|
||||
return {
|
||||
connectionReference: extractNode?.parameters?.connectionReference || '',
|
||||
sharepointFolder: extractNode?.parameters?.sharepointFolder || '',
|
||||
};
|
||||
}
|
||||
|
||||
export const TrusteeExpenseImportView: React.FC = () => {
|
||||
// Use instanceId/mandateId from URL params (always available)
|
||||
const { instanceId, mandateId } = useCurrentInstance();
|
||||
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
|
@ -132,95 +147,84 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [existingAutomation, setExistingAutomation] = useState<ExistingAutomation | null>(null);
|
||||
const [isLoadingAutomation, setIsLoadingAutomation] = useState(true);
|
||||
const [existingWorkflow, setExistingWorkflow] = useState<ExistingWorkflow | null>(null);
|
||||
const [isLoadingWorkflow, setIsLoadingWorkflow] = useState(true);
|
||||
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
|
||||
const [isRunningNow, setIsRunningNow] = useState(false);
|
||||
|
||||
// Find all active Microsoft connections
|
||||
useEffect(() => {
|
||||
const msftConns = connections.filter((c: Connection) =>
|
||||
(c.type === 'msft' || c.authority === 'msft') && c.status === 'active'
|
||||
);
|
||||
setMsftConnections(msftConns);
|
||||
|
||||
// Auto-select if only one connection
|
||||
if (msftConns.length === 1) {
|
||||
setMsftConnection(msftConns[0]);
|
||||
} else if (msftConns.length === 0) {
|
||||
setMsftConnection(null);
|
||||
}
|
||||
// Note: When multiple connections exist, user must select manually
|
||||
}, [connections]);
|
||||
|
||||
// Load existing automation for this feature instance
|
||||
useEffect(() => {
|
||||
const loadExistingAutomation = async () => {
|
||||
if (!instanceId) return;
|
||||
const _loadExistingWorkflow = async () => {
|
||||
if (!instanceId) {
|
||||
setIsLoadingWorkflow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingAutomation(true);
|
||||
setIsLoadingWorkflow(true);
|
||||
try {
|
||||
// Fetch all automations and filter client-side
|
||||
const response = await api.get('/api/automations');
|
||||
const response = await api.get(`/api/workflows/${instanceId}/workflows`);
|
||||
const workflows = response.data?.workflows || response.data?.items || [];
|
||||
|
||||
const automations = response.data?.items || response.data?.data || response.data || [];
|
||||
// Find automation by label AND featureInstanceId
|
||||
const expenseAutomation = automations.find((a: any) =>
|
||||
a.label === 'Expense Import' && a.featureInstanceId === instanceId
|
||||
const expenseWorkflow = workflows.find((wf: any) =>
|
||||
wf.label === EXPENSE_IMPORT_LABEL
|
||||
);
|
||||
|
||||
if (expenseAutomation) {
|
||||
setExistingAutomation({
|
||||
id: expenseAutomation.id,
|
||||
label: expenseAutomation.label,
|
||||
active: expenseAutomation.active,
|
||||
schedule: expenseAutomation.schedule,
|
||||
placeholders: expenseAutomation.placeholders || {}
|
||||
if (expenseWorkflow) {
|
||||
const config = _extractWorkflowConfig(expenseWorkflow);
|
||||
setExistingWorkflow({
|
||||
id: expenseWorkflow.id,
|
||||
label: expenseWorkflow.label,
|
||||
active: expenseWorkflow.active ?? true,
|
||||
connectionReference: config.connectionReference,
|
||||
sharepointFolder: config.sharepointFolder,
|
||||
});
|
||||
// Pre-fill selected folder from existing automation
|
||||
if (expenseAutomation.placeholders?.sharepointFolder) {
|
||||
setSelectedFolder(expenseAutomation.placeholders.sharepointFolder);
|
||||
if (config.sharepointFolder) {
|
||||
setSelectedFolder(config.sharepointFolder);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load existing automation:', err);
|
||||
console.error('Failed to load existing workflow:', err);
|
||||
} finally {
|
||||
setIsLoadingAutomation(false);
|
||||
setIsLoadingWorkflow(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadExistingAutomation();
|
||||
_loadExistingWorkflow();
|
||||
}, [instanceId]);
|
||||
|
||||
// Pre-select connection from existing automation when connections are loaded
|
||||
useEffect(() => {
|
||||
if (!existingAutomation || msftConnections.length === 0 || msftConnection) return;
|
||||
if (!existingWorkflow || msftConnections.length === 0 || msftConnection) return;
|
||||
|
||||
// Try to match connection from existing automation placeholders
|
||||
// Format: "connection:msft:externalUsername" or just the connection ID
|
||||
const savedConnectionRef = existingAutomation.placeholders?.connectionName || '';
|
||||
|
||||
// Try to find matching connection by connectionReference, externalUsername, or id
|
||||
const savedRef = existingWorkflow.connectionReference || '';
|
||||
const matchingConn = msftConnections.find(c =>
|
||||
c.connectionReference === savedConnectionRef ||
|
||||
savedConnectionRef.includes(c.externalUsername || '') ||
|
||||
savedConnectionRef.includes(c.id)
|
||||
c.connectionReference === savedRef ||
|
||||
savedRef.includes(c.externalUsername || '') ||
|
||||
savedRef.includes(c.id)
|
||||
);
|
||||
|
||||
if (matchingConn) {
|
||||
setMsftConnection(matchingConn);
|
||||
} else if (msftConnections.length === 1) {
|
||||
// Fallback to single connection
|
||||
setMsftConnection(msftConnections[0]);
|
||||
}
|
||||
}, [existingAutomation, msftConnections, msftConnection]);
|
||||
}, [existingWorkflow, msftConnections, msftConnection]);
|
||||
|
||||
// Get connection reference from backend computed field (no frontend logic)
|
||||
const getConnectionReference = useCallback((conn: Connection): string => {
|
||||
const _getConnectionReference = useCallback((conn: Connection): string => {
|
||||
return conn.connectionReference || `connection:${conn.authority}:${conn.externalUsername}`;
|
||||
}, []);
|
||||
|
||||
// Load SharePoint sites when connected
|
||||
const loadSiteOptions = useCallback(async () => {
|
||||
if (!msftConnection) return;
|
||||
|
||||
|
|
@ -228,20 +232,19 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const connectionRef = getConnectionReference(msftConnection);
|
||||
const connectionRef = _getConnectionReference(msftConnection);
|
||||
const params = new URLSearchParams({ connectionReference: connectionRef });
|
||||
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
|
||||
setSiteOptions(response.data || []);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load sites:', err);
|
||||
setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load SharePoint sites');
|
||||
setError(_parseErrorDetail(err.response?.data?.detail) || 'Failed to load SharePoint sites');
|
||||
setSiteOptions([]);
|
||||
} finally {
|
||||
setIsLoadingSites(false);
|
||||
}
|
||||
}, [msftConnection, getConnectionReference]);
|
||||
}, [msftConnection, _getConnectionReference]);
|
||||
|
||||
// Load folders when site is selected
|
||||
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
|
||||
if (!msftConnection || !siteId) return;
|
||||
|
||||
|
|
@ -249,7 +252,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const connectionRef = getConnectionReference(msftConnection);
|
||||
const connectionRef = _getConnectionReference(msftConnection);
|
||||
const params = new URLSearchParams({ connectionReference: connectionRef, siteId });
|
||||
if (path) params.append('path', path);
|
||||
|
||||
|
|
@ -257,12 +260,12 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setFolderOptions(response.data || []);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load folders:', err);
|
||||
setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load folders');
|
||||
setError(_parseErrorDetail(err.response?.data?.detail) || 'Failed to load folders');
|
||||
setFolderOptions([]);
|
||||
} finally {
|
||||
setIsLoadingFolders(false);
|
||||
}
|
||||
}, [msftConnection, getConnectionReference]);
|
||||
}, [msftConnection, _getConnectionReference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (msftConnection) {
|
||||
|
|
@ -270,7 +273,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
}
|
||||
}, [msftConnection, loadSiteOptions]);
|
||||
|
||||
// Load root folders when site changes
|
||||
useEffect(() => {
|
||||
if (selectedSite) {
|
||||
setCurrentPath('');
|
||||
|
|
@ -291,7 +293,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleFolderSelect = (folder: FolderOption) => {
|
||||
// Build full path: /sites/SiteName/FolderPath
|
||||
const fullPath = `${selectedSite?.path || ''}/${folder.path}`;
|
||||
setSelectedFolder(fullPath);
|
||||
};
|
||||
|
|
@ -320,43 +321,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const buildTrusteeTemplate = useCallback((connectionRef: string, folder: string) => ({
|
||||
overview: "Trustee document pipeline: extract, process, sync to accounting",
|
||||
tasks: [{
|
||||
id: "Task01",
|
||||
title: "Trustee expense import",
|
||||
description: "Extract from SharePoint, create positions, sync to accounting",
|
||||
objective: "Run trustee.extractFromFiles, processDocuments, syncToAccounting",
|
||||
actionList: [
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "extractFromFiles",
|
||||
execParameters: {
|
||||
connectionReference: connectionRef,
|
||||
sharepointFolder: folder,
|
||||
featureInstanceId: instanceId,
|
||||
prompt: DEFAULT_EXTRACTION_PROMPT
|
||||
},
|
||||
execResultLabel: "extract_result"
|
||||
},
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "processDocuments",
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: "process_result"
|
||||
},
|
||||
{
|
||||
execMethod: "trustee",
|
||||
execAction: "syncToAccounting",
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: "sync_result"
|
||||
}
|
||||
]
|
||||
}]
|
||||
}), [instanceId]);
|
||||
|
||||
const handleSave = async (activate: boolean = true) => {
|
||||
// Validate required fields with user feedback
|
||||
if (!msftConnection) {
|
||||
showError('Missing Connection', 'Please select a Microsoft connection first.');
|
||||
return;
|
||||
|
|
@ -375,57 +340,68 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`;
|
||||
const connectionRef = _getConnectionReference(msftConnection);
|
||||
const graph = _buildScheduledExpenseImportGraph(
|
||||
instanceId,
|
||||
connectionRef,
|
||||
selectedFolder,
|
||||
DEFAULT_EXTRACTION_PROMPT,
|
||||
DAILY_CRON
|
||||
);
|
||||
|
||||
const template = buildTrusteeTemplate(connectionReference, selectedFolder);
|
||||
const automationData = {
|
||||
label: 'Expense Import',
|
||||
schedule: '0 22 * * *', // Daily at 22:00
|
||||
template: JSON.stringify(template),
|
||||
placeholders: {
|
||||
connectionName: connectionReference,
|
||||
sharepointFolder: selectedFolder,
|
||||
featureInstanceId: instanceId
|
||||
},
|
||||
active: activate,
|
||||
mandateId: mandateId,
|
||||
featureInstanceId: instanceId
|
||||
};
|
||||
const invocations = [{
|
||||
id: 'schedule-daily',
|
||||
kind: 'schedule',
|
||||
triggerNodeId: 'trigger-schedule',
|
||||
enabled: activate,
|
||||
label: 'Daily at 22:00',
|
||||
config: { cron: DAILY_CRON },
|
||||
}];
|
||||
|
||||
let response;
|
||||
if (existingAutomation) {
|
||||
// Update existing automation
|
||||
response = await api.put(`/api/automations/${existingAutomation.id}`, {
|
||||
...automationData,
|
||||
id: existingAutomation.id,
|
||||
mandateId: mandateId
|
||||
});
|
||||
if (existingWorkflow) {
|
||||
response = await api.put(
|
||||
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
|
||||
{
|
||||
label: EXPENSE_IMPORT_LABEL,
|
||||
graph,
|
||||
active: activate,
|
||||
invocations,
|
||||
}
|
||||
);
|
||||
const msg = activate
|
||||
? 'Expense import automation updated and activated!'
|
||||
: 'Expense import automation updated and deactivated.';
|
||||
? 'Expense import workflow updated and activated!'
|
||||
: 'Expense import workflow updated and deactivated.';
|
||||
setSuccessMessage(msg);
|
||||
showSuccess('Success', msg);
|
||||
} else {
|
||||
// Create new automation
|
||||
response = await api.post('/api/automations', automationData);
|
||||
const msg = 'Expense import automation created and activated! It will run daily at 22:00.';
|
||||
response = await api.post(
|
||||
`/api/workflows/${instanceId}/workflows`,
|
||||
{
|
||||
label: EXPENSE_IMPORT_LABEL,
|
||||
graph,
|
||||
active: activate,
|
||||
invocations,
|
||||
}
|
||||
);
|
||||
const msg = 'Expense import workflow created and activated! It will run daily at 22:00.';
|
||||
setSuccessMessage(msg);
|
||||
showSuccess('Success', msg);
|
||||
}
|
||||
|
||||
// Update local state with response
|
||||
const savedAutomation = response.data;
|
||||
setExistingAutomation({
|
||||
id: savedAutomation.id,
|
||||
label: savedAutomation.label,
|
||||
active: savedAutomation.active,
|
||||
schedule: savedAutomation.schedule,
|
||||
placeholders: savedAutomation.placeholders || {}
|
||||
const savedWorkflow = response.data;
|
||||
const config = _extractWorkflowConfig(savedWorkflow);
|
||||
setExistingWorkflow({
|
||||
id: savedWorkflow.id,
|
||||
label: savedWorkflow.label,
|
||||
active: savedWorkflow.active ?? activate,
|
||||
connectionReference: config.connectionReference,
|
||||
sharepointFolder: config.sharepointFolder,
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Save failed:', err);
|
||||
const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to save automation';
|
||||
const errorMsg = _parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to save workflow';
|
||||
setError(errorMsg);
|
||||
showError('Error', errorMsg);
|
||||
} finally {
|
||||
|
|
@ -441,13 +417,20 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
setIsRunningNow(true);
|
||||
setError(null);
|
||||
try {
|
||||
const connectionRef = getConnectionReference(msftConnection);
|
||||
const template = buildTrusteeTemplate(connectionRef, selectedFolder);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
await api.post(`/api/automations/${instanceId}/start`, { prompt }, { params: { workflowMode: 'Automation' } });
|
||||
const connectionRef = _getConnectionReference(msftConnection);
|
||||
const graph = _buildExpenseImportGraph(
|
||||
instanceId,
|
||||
connectionRef,
|
||||
selectedFolder,
|
||||
DEFAULT_EXTRACTION_PROMPT
|
||||
);
|
||||
await api.post(
|
||||
`/api/workflows/${instanceId}/execute`,
|
||||
{ graph }
|
||||
);
|
||||
showSuccess('Started', 'Workflow started. Extract → Process → Sync will run once.');
|
||||
} catch (err: any) {
|
||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
const msg = _parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
setError(msg);
|
||||
showError('Error', msg);
|
||||
} finally {
|
||||
|
|
@ -456,23 +439,23 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!existingAutomation) return;
|
||||
if (!existingWorkflow || !instanceId) return;
|
||||
|
||||
setIsActivating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use dedicated PATCH endpoint for status changes
|
||||
await api.patch(`/api/automations/${existingAutomation.id}/status`, {
|
||||
active: false
|
||||
});
|
||||
await api.put(
|
||||
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
|
||||
{ active: false }
|
||||
);
|
||||
|
||||
setExistingAutomation(prev => prev ? { ...prev, active: false } : null);
|
||||
setSuccessMessage('Expense import automation deactivated.');
|
||||
showSuccess('Deactivated', 'Expense import automation deactivated.');
|
||||
setExistingWorkflow(prev => prev ? { ...prev, active: false } : null);
|
||||
setSuccessMessage('Expense import workflow deactivated.');
|
||||
showSuccess('Deactivated', 'Expense import workflow deactivated.');
|
||||
} catch (err: any) {
|
||||
console.error('Deactivation failed:', err);
|
||||
const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to deactivate automation';
|
||||
const errorMsg = _parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to deactivate workflow';
|
||||
setError(errorMsg);
|
||||
showError('Error', errorMsg);
|
||||
} finally {
|
||||
|
|
@ -525,11 +508,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* Current Status */}
|
||||
{!isLoadingAutomation && existingAutomation && (
|
||||
<div className={existingAutomation.active ? styles.successMessage : styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<strong>Current Status:</strong> {existingAutomation.active ? '✓ Active' : '○ Inactive'}
|
||||
{existingAutomation.placeholders?.sharepointFolder && (
|
||||
<><br />Folder: {existingAutomation.placeholders.sharepointFolder}</>
|
||||
{!isLoadingWorkflow && existingWorkflow && (
|
||||
<div className={existingWorkflow.active ? styles.successMessage : styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<strong>Current Status:</strong> {existingWorkflow.active ? '✓ Active' : '○ Inactive'}
|
||||
{existingWorkflow.sharepointFolder && (
|
||||
<><br />Folder: {existingWorkflow.sharepointFolder}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -540,7 +523,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
<div className={styles.stepContent}>
|
||||
<h4>Microsoft Connection</h4>
|
||||
{msftConnections.length === 0 ? (
|
||||
// No connections - show connect button
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleConnect}
|
||||
|
|
@ -549,7 +531,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
{isConnecting ? 'Connecting...' : 'Connect Microsoft Account'}
|
||||
</button>
|
||||
) : msftConnections.length === 1 ? (
|
||||
// Single connection - show as connected
|
||||
<div className={styles.connectionStatus}>
|
||||
<span className={styles.connectedIcon}>✓</span>
|
||||
<span className={styles.connectedText}>
|
||||
|
|
@ -557,7 +538,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
) : (
|
||||
// Multiple connections - show dropdown
|
||||
<>
|
||||
<select
|
||||
className={styles.folderSelect}
|
||||
|
|
@ -565,7 +545,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
onChange={(e) => {
|
||||
const conn = msftConnections.find(c => c.id === e.target.value);
|
||||
setMsftConnection(conn || null);
|
||||
// Reset site/folder selection when connection changes
|
||||
setSelectedSite(null);
|
||||
setSiteOptions([]);
|
||||
setFolderOptions([]);
|
||||
|
|
@ -643,7 +622,6 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => {
|
||||
// Select the current folder path
|
||||
const fullPath = `${selectedSite?.path || ''}/${currentPath || ''}`.replace(/\/+$/, '');
|
||||
setSelectedFolder(fullPath || selectedSite?.path || '');
|
||||
}}
|
||||
|
|
@ -689,7 +667,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber}>4</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4>{existingAutomation ? 'Update Configuration' : 'Activate Daily Import'}</h4>
|
||||
<h4>{existingWorkflow ? 'Update Configuration' : 'Activate Daily Import'}</h4>
|
||||
<p className={styles.activateDescription}>
|
||||
PDF files in <strong>{selectedFolder}</strong> will be processed daily at 22:00.
|
||||
Successfully processed files will be moved to a "processed" subfolder.
|
||||
|
|
@ -700,7 +678,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
onClick={() => handleSave(true)}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
|
||||
{isActivating ? 'Saving...' : (existingWorkflow ? 'Save & Activate' : 'Activate Daily Import')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
|
|
@ -709,7 +687,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
>
|
||||
{isRunningNow ? 'Starting...' : 'Jetzt ausführen'}
|
||||
</button>
|
||||
{existingAutomation && existingAutomation.active && (
|
||||
{existingWorkflow && existingWorkflow.active && (
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleDeactivate}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
* TrusteeScanUploadView (UC1)
|
||||
*
|
||||
* Mobile-friendly scan/upload: photo, drag-and-drop, or file picker.
|
||||
* Uploads files, then starts the trustee pipeline (extract → process → sync) with fileIds.
|
||||
* Uploads files, then starts the trustee pipeline (extract → process → sync)
|
||||
* via the consolidated graphicalEditor execution engine.
|
||||
*
|
||||
* The /api/workflows/ routes accept any feature instanceId the user has access to;
|
||||
* no separate graphicalEditor instance is needed.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
|
|
@ -10,6 +14,7 @@ import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
|||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { FileContext } from '../../../contexts/FileContext';
|
||||
import api from '../../../api';
|
||||
import { _buildScanUploadGraph } from './trusteePipelineGraph';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
const DEFAULT_EXTRACTION_PROMPT = `Extrahiere Spesendaten aus dem Dokument. Gib Dokumenttyp (INVOICE, EXPENSE_RECEIPT, …) und Datensätze zurück. CSV-Spalten: valuta,company,desc,bookingAmount,bookingCurrency,vatPercentage,vatAmount,debitAccountNumber,creditAccountNumber,tags.`;
|
||||
|
|
@ -21,7 +26,7 @@ interface UploadedEntry {
|
|||
|
||||
type PipelineState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
||||
|
||||
const parseErrorDetail = (detail: unknown): string => {
|
||||
const _parseErrorDetail = (detail: unknown): string => {
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return (detail as Array<{ msg?: string }>).map((e) => e.msg || JSON.stringify(e)).join(', ');
|
||||
|
|
@ -42,10 +47,9 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
const [pipelineState, setPipelineState] = useState<PipelineState>('idle');
|
||||
const [pipelineSummary, setPipelineSummary] = useState<string>('');
|
||||
const [pipelineDetail, setPipelineDetail] = useState<string>('');
|
||||
const [pipelineWorkflowId, setPipelineWorkflowId] = useState<string | null>(null);
|
||||
const [pipelineRunId, setPipelineRunId] = useState<string | null>(null);
|
||||
const [lastPollAt, setLastPollAt] = useState<number | null>(null);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
const latestTimestampRef = useRef<number | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
|
||||
const handleFileUpload = fileContext?.handleFileUpload;
|
||||
|
|
@ -114,44 +118,6 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
[onFiles]
|
||||
);
|
||||
|
||||
const buildTemplate = useCallback(
|
||||
(fileIds: string[]) => ({
|
||||
overview: 'Trustee pipeline from uploaded files',
|
||||
tasks: [
|
||||
{
|
||||
id: 'Task01',
|
||||
title: 'Extract, process, sync',
|
||||
objective: 'Run trustee pipeline on uploaded files',
|
||||
actionList: [
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'extractFromFiles',
|
||||
execParameters: {
|
||||
fileIds,
|
||||
featureInstanceId: instanceId,
|
||||
prompt: DEFAULT_EXTRACTION_PROMPT,
|
||||
},
|
||||
execResultLabel: 'extract_result',
|
||||
},
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'processDocuments',
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: 'process_result',
|
||||
},
|
||||
{
|
||||
execMethod: 'trustee',
|
||||
execAction: 'syncToAccounting',
|
||||
execParameters: { documentList: [], featureInstanceId: instanceId },
|
||||
execResultLabel: 'sync_result',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[instanceId]
|
||||
);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
|
|
@ -160,91 +126,46 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
isPollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const pollWorkflowStatus = useCallback(
|
||||
async (workflowId: string) => {
|
||||
if (!instanceId || !workflowId || isPollingRef.current) return;
|
||||
const pollRunStatus = useCallback(
|
||||
async (runId: string) => {
|
||||
if (!instanceId || !runId || isPollingRef.current) return;
|
||||
isPollingRef.current = true;
|
||||
try {
|
||||
const chatDataRes = await api.get(
|
||||
`/api/automations/${instanceId}/workflows/${workflowId}/chatData`,
|
||||
{
|
||||
params: latestTimestampRef.current
|
||||
? { afterTimestamp: latestTimestampRef.current }
|
||||
: undefined,
|
||||
}
|
||||
const stepsRes = await api.get(
|
||||
`/api/workflows/${instanceId}/runs/${runId}/steps`
|
||||
);
|
||||
const steps = Array.isArray(stepsRes?.data?.steps) ? stepsRes.data.steps : [];
|
||||
|
||||
const chatItems = Array.isArray(chatDataRes?.data?.items)
|
||||
? chatDataRes.data.items
|
||||
: [];
|
||||
const completedSteps = steps.filter((s: any) => s.status === 'completed');
|
||||
const failedSteps = steps.filter((s: any) => s.status === 'failed');
|
||||
const runningSteps = steps.filter((s: any) => s.status === 'running');
|
||||
const latestStep = steps.length > 0 ? steps[steps.length - 1] : null;
|
||||
|
||||
const latestCreatedAt = chatItems.reduce((acc: number, item: any) => {
|
||||
const createdAt = Number(item?.createdAt || 0);
|
||||
return createdAt > acc ? createdAt : acc;
|
||||
}, latestTimestampRef.current || 0);
|
||||
if (latestCreatedAt > 0) {
|
||||
latestTimestampRef.current = latestCreatedAt;
|
||||
}
|
||||
|
||||
const logMessages = chatItems
|
||||
.filter((item: any) => item?.type === 'log')
|
||||
.map((item: any) =>
|
||||
(item?.item?.message || item?.item?.status || '').toString().trim()
|
||||
)
|
||||
.filter((msg: string) => msg.length > 0);
|
||||
const latestLog = logMessages.length
|
||||
? logMessages[logMessages.length - 1]
|
||||
: '';
|
||||
if (latestLog) {
|
||||
setPipelineDetail(latestLog);
|
||||
}
|
||||
|
||||
const statItems = chatItems.filter((item: any) => item?.type === 'stat');
|
||||
const latestStat =
|
||||
statItems.length > 0 ? statItems[statItems.length - 1]?.item : null;
|
||||
const rawStatus = (
|
||||
latestStat?.status || 'running'
|
||||
).toString().toLowerCase();
|
||||
|
||||
const messageItems = chatItems.filter(
|
||||
(item: any) => item?.type === 'message'
|
||||
);
|
||||
const completionMessage = messageItems.find(
|
||||
(item: any) =>
|
||||
(item?.item?.message || '').toString().toLowerCase().startsWith('completed:')
|
||||
);
|
||||
|
||||
const isCompleted =
|
||||
rawStatus === 'completed' ||
|
||||
rawStatus === 'stopped' ||
|
||||
!!completionMessage;
|
||||
|
||||
const totalLogs = logMessages.length;
|
||||
const totalMessages = messageItems.length;
|
||||
setPipelineSummary(
|
||||
`Workflow ${workflowId.slice(0, 8)} — ${totalMessages} message(s), ${totalLogs} log(s)`
|
||||
);
|
||||
setLastPollAt(Date.now());
|
||||
setPipelineSummary(
|
||||
`Run ${runId.slice(0, 8)} — ${completedSteps.length}/${steps.length} steps completed`
|
||||
);
|
||||
|
||||
if (isCompleted) {
|
||||
setPipelineState('completed');
|
||||
if (latestStep) {
|
||||
const label = latestStep.nodeType || latestStep.nodeId || '';
|
||||
const status = latestStep.status || '';
|
||||
setPipelineDetail(`${label}: ${status}`);
|
||||
}
|
||||
|
||||
if (failedSteps.length > 0) {
|
||||
const failedStep = failedSteps[failedSteps.length - 1];
|
||||
const errMsg = failedStep.error || 'Step failed';
|
||||
setPipelineState('error');
|
||||
setError(errMsg);
|
||||
stopPolling();
|
||||
showSuccess(
|
||||
'Pipeline completed',
|
||||
'Extraction and processing workflow finished successfully.'
|
||||
);
|
||||
showError('Pipeline error', errMsg);
|
||||
return;
|
||||
}
|
||||
if (rawStatus === 'error') {
|
||||
setPipelineState('error');
|
||||
|
||||
if (runningSteps.length === 0 && completedSteps.length === steps.length && steps.length > 0) {
|
||||
setPipelineState('completed');
|
||||
stopPolling();
|
||||
if (latestLog) {
|
||||
setError(latestLog);
|
||||
}
|
||||
showError(
|
||||
'Pipeline error',
|
||||
latestLog || 'Workflow ended with status "error".'
|
||||
);
|
||||
showSuccess('Pipeline completed', 'Extraction and processing workflow finished successfully.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -254,15 +175,11 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
setPipelineState('running');
|
||||
return;
|
||||
}
|
||||
const msg =
|
||||
parseErrorDetail(pollErr.response?.data?.detail) ||
|
||||
pollErr.message ||
|
||||
'Polling failed';
|
||||
const msg = _parseErrorDetail(pollErr.response?.data?.detail) || pollErr.message || 'Polling failed';
|
||||
setPipelineState('error');
|
||||
setError(msg);
|
||||
setPipelineSummary(`Workflow status polling failed: ${msg}`);
|
||||
showError('Polling error', msg);
|
||||
stopPolling();
|
||||
showError('Polling error', msg);
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
|
|
@ -271,19 +188,19 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId || !pipelineWorkflowId || (pipelineState !== 'running' && pipelineState !== 'starting')) {
|
||||
if (!instanceId || !pipelineRunId || (pipelineState !== 'running' && pipelineState !== 'starting')) {
|
||||
return;
|
||||
}
|
||||
|
||||
void pollWorkflowStatus(pipelineWorkflowId);
|
||||
void pollRunStatus(pipelineRunId);
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
void pollWorkflowStatus(pipelineWorkflowId);
|
||||
void pollRunStatus(pipelineRunId);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [instanceId, pipelineWorkflowId, pipelineState, pollWorkflowStatus, stopPolling]);
|
||||
}, [instanceId, pipelineRunId, pipelineState, pollRunStatus, stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -301,33 +218,38 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
setPipelineState('starting');
|
||||
setPipelineSummary('Starting pipeline workflow...');
|
||||
setPipelineDetail('');
|
||||
latestTimestampRef.current = null;
|
||||
try {
|
||||
const fileIds = uploadedFiles.map((f) => f.fileId);
|
||||
const template = buildTemplate(fileIds);
|
||||
const prompt = `<!--TEMPLATE_PLAN_START-->\n${JSON.stringify(template)}\n<!--TEMPLATE_PLAN_END-->`;
|
||||
const graph = _buildScanUploadGraph(instanceId, fileIds, DEFAULT_EXTRACTION_PROMPT);
|
||||
const response = await api.post(
|
||||
`/api/automations/${instanceId}/start`,
|
||||
{ prompt },
|
||||
{ params: { workflowMode: 'Automation' } }
|
||||
`/api/workflows/${instanceId}/execute`,
|
||||
{ graph }
|
||||
);
|
||||
const workflowId = response?.data?.id || response?.data?.workflowId || null;
|
||||
if (!workflowId) {
|
||||
throw new Error('Workflow started but no workflow ID was returned by backend.');
|
||||
const runId = response?.data?.runId || null;
|
||||
if (!runId) {
|
||||
const success = response?.data?.success;
|
||||
if (success) {
|
||||
setPipelineState('completed');
|
||||
setPipelineSummary('Pipeline completed (synchronous execution).');
|
||||
showSuccess('Completed', 'Pipeline finished: Extract → Process → Sync.');
|
||||
} else {
|
||||
throw new Error(response?.data?.error || 'Workflow executed but no run ID returned.');
|
||||
}
|
||||
} else {
|
||||
setPipelineRunId(runId);
|
||||
setPipelineState('running');
|
||||
setPipelineSummary(`Run ${runId.slice(0, 8)} started. Waiting for progress updates...`);
|
||||
showSuccess('Started', 'Pipeline started: Extract → Process → Sync. You can follow progress in Workflows.');
|
||||
}
|
||||
setPipelineWorkflowId(workflowId);
|
||||
setPipelineState('running');
|
||||
setPipelineSummary(`Workflow ${workflowId.slice(0, 8)} started. Waiting for progress updates...`);
|
||||
showSuccess('Started', 'Pipeline started: Extract → Process → Sync. You can follow progress in Workflows.');
|
||||
} catch (err: any) {
|
||||
const msg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
const msg = _parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to start workflow';
|
||||
setPipelineState('error');
|
||||
setError(msg);
|
||||
showError('Error', msg);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [instanceId, uploadedFiles, buildTemplate, showSuccess, showError]);
|
||||
}, [instanceId, uploadedFiles, showSuccess, showError]);
|
||||
|
||||
if (!fileContext) {
|
||||
return (
|
||||
|
|
@ -387,7 +309,6 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
/>
|
||||
{uploadingFile ? 'Uploading…' : 'Choose files'}
|
||||
</label>
|
||||
{/* Mobile: optional camera capture */}
|
||||
<label className={styles.secondaryButton} style={{ cursor: 'pointer', display: 'inline-block', marginLeft: '0.5rem' }}>
|
||||
<input
|
||||
type="file"
|
||||
|
|
|
|||
239
src/pages/views/trustee/trusteePipelineGraph.ts
Normal file
239
src/pages/views/trustee/trusteePipelineGraph.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Builds graphicalEditor-compatible graph structures for trustee pipeline execution.
|
||||
*
|
||||
* The consolidated automation system runs all workflows through the graphicalEditor
|
||||
* execution engine (POST /api/workflows/{instanceId}/execute). These helpers build
|
||||
* the graph format expected by that engine: { nodes, connections } with _method/_action
|
||||
* mappings to the unified Action Library.
|
||||
*/
|
||||
|
||||
interface TrusteeGraphNode {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
_method: string;
|
||||
_action: string;
|
||||
parameters: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface TrusteeGraphConnection {
|
||||
source: string;
|
||||
sourcePort: number;
|
||||
target: string;
|
||||
targetPort: number;
|
||||
}
|
||||
|
||||
export interface TrusteeGraph {
|
||||
nodes: TrusteeGraphNode[];
|
||||
connections: TrusteeGraphConnection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a graph for the scan/upload pipeline (UC1):
|
||||
* trigger.manual → trustee.extractFromFiles → trustee.processDocuments → trustee.syncToAccounting
|
||||
*/
|
||||
export function _buildScanUploadGraph(
|
||||
trusteeInstanceId: string,
|
||||
fileIds: string[],
|
||||
extractionPrompt: string
|
||||
): TrusteeGraph {
|
||||
const nodes: TrusteeGraphNode[] = [
|
||||
{
|
||||
id: 'trigger-manual',
|
||||
type: 'trigger.manual',
|
||||
label: 'Start',
|
||||
_method: '',
|
||||
_action: '',
|
||||
parameters: {},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'extract',
|
||||
type: 'trustee.extractFromFiles',
|
||||
label: 'Extract Documents',
|
||||
_method: 'trustee',
|
||||
_action: 'extractFromFiles',
|
||||
parameters: {
|
||||
fileIds,
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
prompt: extractionPrompt,
|
||||
},
|
||||
position: { x: 250, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'process',
|
||||
type: 'trustee.processDocuments',
|
||||
label: 'Process Documents',
|
||||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
type: 'trustee.syncToAccounting',
|
||||
label: 'Sync to Accounting',
|
||||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a graph for the expense import pipeline (SharePoint-based):
|
||||
* trigger.manual → trustee.extractFromFiles (SharePoint) → trustee.processDocuments → trustee.syncToAccounting
|
||||
*/
|
||||
export function _buildExpenseImportGraph(
|
||||
trusteeInstanceId: string,
|
||||
connectionReference: string,
|
||||
sharepointFolder: string,
|
||||
extractionPrompt: string
|
||||
): TrusteeGraph {
|
||||
const nodes: TrusteeGraphNode[] = [
|
||||
{
|
||||
id: 'trigger-manual',
|
||||
type: 'trigger.manual',
|
||||
label: 'Start',
|
||||
_method: '',
|
||||
_action: '',
|
||||
parameters: {},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'extract',
|
||||
type: 'trustee.extractFromFiles',
|
||||
label: 'Extract from SharePoint',
|
||||
_method: 'trustee',
|
||||
_action: 'extractFromFiles',
|
||||
parameters: {
|
||||
connectionReference,
|
||||
sharepointFolder,
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
prompt: extractionPrompt,
|
||||
},
|
||||
position: { x: 250, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'process',
|
||||
type: 'trustee.processDocuments',
|
||||
label: 'Process Documents',
|
||||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
type: 'trustee.syncToAccounting',
|
||||
label: 'Sync to Accounting',
|
||||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-manual', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a scheduled workflow graph for daily expense import:
|
||||
* trigger.schedule (cron) → trustee.extractFromFiles → trustee.processDocuments → trustee.syncToAccounting
|
||||
*/
|
||||
export function _buildScheduledExpenseImportGraph(
|
||||
trusteeInstanceId: string,
|
||||
connectionReference: string,
|
||||
sharepointFolder: string,
|
||||
extractionPrompt: string,
|
||||
cronExpression: string
|
||||
): TrusteeGraph {
|
||||
const nodes: TrusteeGraphNode[] = [
|
||||
{
|
||||
id: 'trigger-schedule',
|
||||
type: 'trigger.schedule',
|
||||
label: 'Daily Schedule',
|
||||
_method: '',
|
||||
_action: '',
|
||||
parameters: {
|
||||
cron: cronExpression,
|
||||
enabled: true,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'extract',
|
||||
type: 'trustee.extractFromFiles',
|
||||
label: 'Extract from SharePoint',
|
||||
_method: 'trustee',
|
||||
_action: 'extractFromFiles',
|
||||
parameters: {
|
||||
connectionReference,
|
||||
sharepointFolder,
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
prompt: extractionPrompt,
|
||||
},
|
||||
position: { x: 250, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'process',
|
||||
type: 'trustee.processDocuments',
|
||||
label: 'Process Documents',
|
||||
_method: 'trustee',
|
||||
_action: 'processDocuments',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 500, y: 0 },
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
type: 'trustee.syncToAccounting',
|
||||
label: 'Sync to Accounting',
|
||||
_method: 'trustee',
|
||||
_action: 'syncToAccounting',
|
||||
parameters: {
|
||||
documentList: [],
|
||||
featureInstanceId: trusteeInstanceId,
|
||||
},
|
||||
position: { x: 750, y: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const connections: TrusteeGraphConnection[] = [
|
||||
{ source: 'trigger-schedule', sourcePort: 0, target: 'extract', targetPort: 0 },
|
||||
{ source: 'extract', sourcePort: 0, target: 'process', targetPort: 0 },
|
||||
{ source: 'process', sourcePort: 0, target: 'sync', targetPort: 0 },
|
||||
];
|
||||
|
||||
return { nodes, connections };
|
||||
}
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Language, TranslationKeys, loadLanguage } from '../../locales';
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { Language, TranslationKeys, loadLanguage, fetchAvailableLanguageCodes, I18nCodeInfo } from '../../locales';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
|
||||
|
||||
export type { Language };
|
||||
|
||||
type TranslateParams = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
interface LanguageContextType {
|
||||
currentLanguage: Language;
|
||||
setLanguage: (language: Language) => void;
|
||||
t: (key: string, fallback?: string) => string;
|
||||
t: (key: string, paramsOrFallback?: TranslateParams | string) => string;
|
||||
isLoading: boolean;
|
||||
reloadLanguage: () => Promise<void>;
|
||||
availableLanguages: I18nCodeInfo[];
|
||||
refreshAvailableLanguages: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
|
@ -22,14 +26,19 @@ interface LanguageProviderProps {
|
|||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||
const [currentLanguage, setCurrentLanguage] = useState<Language>('de');
|
||||
const [translations, setTranslations] = useState<TranslationKeys>({});
|
||||
const [deTranslations, setDeTranslations] = useState<TranslationKeys>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]);
|
||||
|
||||
// Function to load and set a language
|
||||
const loadAndSetLanguage = async (language: Language) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newTranslations = await loadLanguage(language);
|
||||
setTranslations(newTranslations);
|
||||
const deKeys = await loadLanguage('de');
|
||||
setDeTranslations(deKeys);
|
||||
const targetKeys =
|
||||
language === 'de' ? deKeys : await loadLanguage(language);
|
||||
setTranslations(targetKeys);
|
||||
setCurrentLanguage(language);
|
||||
} catch (error) {
|
||||
console.error('Failed to load language:', error);
|
||||
|
|
@ -46,8 +55,8 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
|
||||
// Priority 1: Check if user data has language setting (ONLY source of truth!)
|
||||
const userData = getUserDataCache();
|
||||
if (userData?.language && ['de', 'en', 'fr'].includes(userData.language)) {
|
||||
initialLanguage = userData.language as Language;
|
||||
if (userData?.language && String(userData.language).trim()) {
|
||||
initialLanguage = String(userData.language).trim() as Language;
|
||||
console.log('🌍 Using language from user profile (sessionStorage cache):', initialLanguage);
|
||||
await loadAndSetLanguage(initialLanguage);
|
||||
return;
|
||||
|
|
@ -55,10 +64,16 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
|
||||
// Priority 2: Detect browser language (fallback only if no user data)
|
||||
const browserLang = navigator.language.split('-')[0] as Language;
|
||||
if (['de', 'en', 'fr'].includes(browserLang)) {
|
||||
initialLanguage = browserLang;
|
||||
console.log('🌍 Using browser language as fallback:', initialLanguage);
|
||||
} else {
|
||||
try {
|
||||
const codes = await fetchAvailableLanguageCodes();
|
||||
const codeSet = new Set(codes.map((c) => c.code));
|
||||
if (codeSet.has(browserLang)) {
|
||||
initialLanguage = browserLang;
|
||||
console.log('🌍 Using browser language as fallback:', initialLanguage);
|
||||
} else {
|
||||
console.log('🌍 Using default language:', initialLanguage);
|
||||
}
|
||||
} catch {
|
||||
console.log('🌍 Using default language:', initialLanguage);
|
||||
}
|
||||
|
||||
|
|
@ -70,8 +85,8 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
// Listen for user data updates to sync language
|
||||
const handleUserUpdate = () => {
|
||||
const userData = getUserDataCache();
|
||||
if (userData?.language && ['de', 'en', 'fr'].includes(userData.language)) {
|
||||
const userLanguage = userData.language as Language;
|
||||
if (userData?.language && String(userData.language).trim()) {
|
||||
const userLanguage = String(userData.language).trim() as Language;
|
||||
if (userLanguage !== currentLanguage) {
|
||||
console.log('🔄 Syncing language with user data (sessionStorage cache):', userLanguage);
|
||||
loadAndSetLanguage(userLanguage);
|
||||
|
|
@ -103,9 +118,43 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
await loadAndSetLanguage(currentLanguage);
|
||||
};
|
||||
|
||||
const t = (key: string, fallback?: string): string => {
|
||||
const translation = translations[key] || fallback || key;
|
||||
return translation;
|
||||
const refreshAvailableLanguages = useCallback(async () => {
|
||||
try {
|
||||
const list = await fetchAvailableLanguageCodes();
|
||||
setAvailableLanguages(list);
|
||||
} catch (e) {
|
||||
console.error('Failed to load language codes:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAvailableLanguages();
|
||||
}, [refreshAvailableLanguages]);
|
||||
|
||||
const _applyParams = (template: string, params?: TranslateParams): string => {
|
||||
if (!params) return template;
|
||||
let out = template;
|
||||
for (const [paramKey, rawVal] of Object.entries(params)) {
|
||||
if (rawVal === undefined || rawVal === null) continue;
|
||||
out = out.split(`{${paramKey}}`).join(String(rawVal));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const t = (key: string, paramsOrFallback?: TranslateParams | string): string => {
|
||||
let params: TranslateParams | undefined;
|
||||
if (typeof paramsOrFallback === 'string') {
|
||||
params = undefined;
|
||||
} else {
|
||||
params = paramsOrFallback;
|
||||
}
|
||||
|
||||
const resolved =
|
||||
translations[key] ??
|
||||
deTranslations[key] ??
|
||||
(typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
|
||||
`[${key}]`;
|
||||
return _applyParams(resolved, params);
|
||||
};
|
||||
|
||||
const contextValue: LanguageContextType = {
|
||||
|
|
@ -113,7 +162,9 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
setLanguage,
|
||||
t,
|
||||
isLoading,
|
||||
reloadLanguage
|
||||
reloadLanguage,
|
||||
availableLanguages,
|
||||
refreshAvailableLanguages,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in a new issue