ui all languages transferred

This commit is contained in:
ValueOn AG 2026-04-09 00:11:35 +02:00
parent 9661a0f7a5
commit c3646075ff
146 changed files with 3118 additions and 1787 deletions

View file

@ -0,0 +1,182 @@
# i18n — Verbleibende statische Texte
> Stand: 2026-04-08
> Diese Stellen verwenden noch hardcoded Strings in Object-Literalen, Arrays oder Hook-Defaults.
> Sie können nicht einfach mit `t()` gewrapped werden, da sie ausserhalb des React-Render-Kontexts definiert sind.
> **Lösung:** Array/Object in die Komponente verschieben oder eine Factory-Funktion `(t) => [...]` nutzen.
---
## 1. Hook-Defaults (`useConfirm`, `usePrompt`)
Diese Defaults propagieren in die gesamte App. Ein Fix hier wirkt global.
| Datei | Zeile | Property | Text |
|-------|-------|----------|------|
| `hooks/useConfirm.tsx` | 26 | `title` | `'Bestätigung'` |
| `hooks/useConfirm.tsx` | 27 | `confirmLabel` | `'Bestätigen'` |
| `hooks/useConfirm.tsx` | 28 | `cancelLabel` | `'Abbrechen'` |
| `hooks/usePrompt.tsx` | 29 | `title` | `'Eingabe'` |
| `hooks/usePrompt.tsx` | 30 | `confirmLabel` | `'OK'` |
| `hooks/usePrompt.tsx` | 31 | `cancelLabel` | `'Abbrechen'` |
---
## 2. Monatsnamen
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/billing/BillingDashboard.tsx` | 182193 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
| `components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx` | 536541 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
---
## 3. Tab-Labels (statische Arrays ausserhalb Komponente)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/Settings.tsx` | 2327 | `'Profil'`, `'Darstellung'`, `'Stimme & Sprache'`, `'Neutralisierung (lokal)'`, `'Datenschutz'` |
| `pages/views/workspace/WorkspaceSettingsPage.tsx` | 1617 | `'Generelle Einstellungen'`, `'Neutralisierung (Workspace)'` |
| `pages/views/neutralization/NeutralizationView.tsx` | 744745 | `'Configuration'`, `'Playground'` |
---
## 4. Spalten-Definitionen (Column-Arrays)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/admin/AdminLanguagesPage.tsx` | 2932 | `'Code'`, `'Bezeichnung'`, `'Status'`, `'Einträge'` |
| `pages/admin/AdminSubscriptionsPage.tsx` | 1323 | `'Mandant'`, `'Plan'`, `'Status'`, `'Wiederkehrend'`, `'User'`, `'Instanzen'`, `'Revenue/Mt (CHF)'`, `'Gestartet'`, `'Periodenende'`, `'Preis/User'`, `'Preis/Instanz'` |
| `pages/admin/AdminUserMandatesPage.tsx` | 104144 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 106124 | `'Bezeichnung'`, `'Beschreibung'`, `'Geltungsbereich'` |
| `pages/admin/AdminInvitationsPage.tsx` | 90155 | `'Benutzername'`, `'E-Mail'`, `'Rollen'`, `'Gültig bis'`, `'Verwendet'`, `'Erstellt'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 138155 | `'Rollen-Label'`, `'Beschreibung'`, `'Feature'` |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 205245 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 91104 | `'Name'`, `'Feature'`, `'Aktiv'` |
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 184235 | `'Workflow'`, `'Aktiv'`, `'Läuft'`, `'Steht bei'`, `'Erstellt'`, `'Zuletzt gestartet'`, `'Läufe'` |
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 174202 | `'Vorlage'`, `'Scope'`, `'Freigegeben'`, `'Erstellt von'`, `'Erstellt'` |
---
## 5. Formular-Feld-Definitionen (AttributeDefinition-Arrays)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/Settings.tsx` | 5658 | `'Vollstaendiger Name'`, `'E-Mail-Adresse'`, `'Sprache'` + descriptions + placeholders |
| `pages/admin/wizards/FeatureInstanceWizard.tsx` | 7578 | `'Mandant'`, `'Feature'`, `'Bezeichnung'`, `'Aktiv'` |
| `pages/admin/InstanceDetailModal.tsx` | 186279 | `'Benutzer'`, `'Rollen'`, `'Aktiv'`, `'Einstellungen'`, `'Bezeichnung'`, `'Aktiviert'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 165171 | `'Geltungsbereich'`, `'Nur dieser Mandant'`, `'Template (wird bei neuen Mandanten kopiert)'` |
| `pages/admin/AdminMandateRolePermissionsPage.tsx` | 219221 | `'Mandanten-Rollen'`, `'Alle (inkl. Templates)'`, `'Nur Templates'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 173205 | `'Rollen-Label'`, `'Beschreibung'` + descriptions |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 272299 | `'Benutzer'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminInvitationsPage.tsx` | 181 | `'Gültigkeitsdauer (Stunden)'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 629636 | `'Bezeichnung'`, `'Aktiviert'` |
---
## 6. Status-/Option-Maps (Object-Literale)
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/billing/SubscriptionTab.tsx` | 4853 | Status-Map: `'Zahlung ausstehend'`, `'Geplant'`, `'Aktiv'`, `'Testphase'`, `'Abgelaufen'` |
| `components/FlowEditor/editor/CanvasHeader.tsx` | 4042 | Status-Map: `'Entwurf'`, `'Veröffentlicht'`, `'Archiviert'` |
| `components/FlowEditor/editor/WorkflowConfigurationModal.tsx` | 1720 | Trigger-Typen: `'Manueller Trigger'`, `'Formular'`, `'Zeitplan'`, `'Immer aktiv'` |
| `components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx` | 2249 | Schedule-Optionen: `'Täglich'`, `'Werktage'`, `'Bestimmte Tage'`, `'Intervall'`, `'Sekunden'`, `'Minuten'`, `'Stunden'`, `'Tage'`, `'Jahre'` |
| `components/RbacExportImport/RbacExportImport.tsx` | 4962 | Import-Modi: `'Zusammenführen'`, `'Nur hinzufügen'`, `'Ersetzen'` + descriptions |
| `components/AccessRules/AccessRulesEditor.tsx` | 633636 | Tab-Labels: `'Daten'`, `'Ressourcen'` |
| `hooks/useAccessRules.tsx` | 2326 | Scope-Labels: `'Keine'`, `'Eigene'`, `'Gruppe'`, `'Alle'` |
---
## 7. Action-Button `title:`-Props (in Object-Literalen)
| Datei | Zeilen | Titles |
|-------|--------|--------|
| `pages/admin/AdminUsersPage.tsx` | 200212 | `'Bearbeiten'`, `'Löschen'`, `'Passwort-Link senden'` |
| `pages/admin/AdminUserMandatesPage.tsx` | 352356 | `'Rollen bearbeiten'`, `'Aus Mandant entfernen'` |
| `pages/admin/AdminMandatesPage.tsx` | 127234 | `'Mandant deaktivieren'`, `'Deaktivieren'`, `'Hard Delete (irreversibel)'`, `'Endgültig löschen'`, `'Bearbeiten'`, `'Deaktivieren (Soft-Delete)'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 430435 | `'Rolle bearbeiten'`, `'Rolle löschen'` |
| `pages/admin/AdminInvitationsPage.tsx` | 354362 | `'Einladung widerrufen'`, `'Einladungs-Link anzeigen'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 372384 | `'Rolle bearbeiten'`, `'Rolle löschen'`, `'Berechtigungen verwalten'` |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 535539 | `'Rollen bearbeiten'`, `'Aus Instanz entfernen'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 457471 | `'Instanz löschen'`, `'Instanz bearbeiten'`, `'Rollen synchronisieren'` |
| `pages/admin/PermissionMatrix.tsx` | 39 | `'Benutzer entfernen'` |
| `pages/basedata/ConnectionsPage.tsx` | 324347 | `'Bearbeiten'`, `'Löschen'`, `'Verbinden'`, `'Token erneuern'` |
| `pages/basedata/FilesPage.tsx` | 232458 | `'Neuer Ordner'`, `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'`, `'Vorschau'` |
| `pages/basedata/PromptsPage.tsx` | 215225 | `'Duplizieren'`, `'Bearbeiten'`, `'Löschen'` |
| `pages/billing/AdminSubscriptionsPage.tsx` | 67 | `'Sofort kündigen'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 455467 | `'Bearbeiten'`, `'Löschen'`, `'In Buchhaltung synchronisieren'` |
| `pages/views/trustee/TrusteePositionDocumentsView.tsx` | 192198 | `'Verknüpfung bearbeiten'`, `'Verknüpfung entfernen'` |
| `pages/views/trustee/TrusteeDocumentsView.tsx` | 225238 | `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'` |
| `pages/views/realestate/RealEstateProjectsView.tsx` | 167168 | `'Bearbeiten'`, `'Löschen'` |
| `pages/views/realestate/RealEstateParcelsView.tsx` | 186194 | `'Bearbeiten'`, `'Löschen'` |
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 136336 | `'Workflow umbenennen'`, `'Bearbeiten'`, `'Löschen'`, `'Umbenennen'`, `'Aktivieren'`, `'Deaktivieren'`, `'Ausführen'` |
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 149289 | `'Vorlage umbenennen'`, `'Im Editor öffnen'`, `'Löschen'`, `'Umbenennen'`, `'Als Workflow kopieren'`, `'Scope ändern'` |
| `pages/views/chatbot/ChatbotConversationsView.tsx` | 88 | `'Konversation löschen'` |
| `components/FlowEditor/editor/Automation2FlowEditor.tsx` | 239 | `'Workflow speichern'` |
| `components/FolderTree/FolderTree.tsx` | 384, 768 | `'Neuer Ordner'` (prompt title) |
---
## 8. Onboarding-Texte (Object-Literale)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `components/OnboardingAssistant.tsx` | 99149 | `'Mandant einrichten'`, `'Erstes Feature aktivieren'`, `'Erste Datenquelle einbinden'`, `'Ersten AI-Chat starten'` |
---
## 9. ClickUp Node Config (Feld-Optionen)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `components/FlowEditor/nodes/configs/ClickUpNodeConfig.tsx` | 786794 | `'Titel (name)'`, `'Beschreibung'`, `'Status'`, `'Priorität (14)'`, `'Fälligkeit (Datum oder ms)'`, `'Zeitschätzung (Stunden)'`, `'Zeitschätzung (ms)'`, `'Zugewiesene'`, `'Benutzerdefiniertes Feld'` |
---
## 10. Sonstige Einzel-Stellen
| Datei | Zeile | Property | Text |
|-------|-------|----------|------|
| `pages/admin/ChatbotConfigSection.tsx` | 58 | `label` | `'Althaus Preprocessor'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 167 | `label` | `'Belege'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 232 | `label` | `'Sync-Status'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 445 | `label` | `'Buchhaltung synchronisieren'` |
| `pages/views/trustee/TrusteeExpenseImportView.tsx` | 361 | `label` | `'Daily at 22:00'` |
| `pages/basedata/PromptsPage.tsx` | 81 | `label` | `'Created By'` |
| `pages/basedata/FilesPage.tsx` | 152 | `label` | `'Created By'` |
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 22 | `label` | `'Feld 1'` |
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 116 | `label` | `'Neues Feld'` |
---
## 11. Sprach-/Locale-Listen (Eigenname-Labels — evtl. NICHT übersetzen)
> Diese Listen enthalten Sprachnamen in der jeweiligen Sprache (Endonym). Sie werden typischerweise **nicht** übersetzt, da der User die Sprache in ihrer Originalbezeichnung erkennen soll.
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/Settings.tsx` | 5052, 563565 | Fallback-Sprachoptionen: `'Deutsch'`, `'English'`, `'Français'` |
| `pages/views/workspace/WorkspaceInput.tsx` | 1627 | STT-Sprachliste (12 Sprachen) |
| `pages/admin/AdminLanguagesPage.tsx` | 3865 | Alle verfügbaren Sprach-Codes mit Endonymen |
| `components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx` | 2332 | Voice-Sprachliste mit Endonymen |
| `components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx` | 672675 | Fallback-Sprachoptionen |
---
## Zusammenfassung
| Kategorie | Anzahl Stellen | Dateien |
|-----------|---------------|---------|
| Hook-Defaults | 6 | 2 |
| Monatsnamen | 24 | 2 |
| Tab-Labels | 9 | 3 |
| Spalten-Definitionen | ~55 | 10 |
| Formular-Felder | ~30 | 9 |
| Status-/Option-Maps | ~30 | 7 |
| Action-Button titles | ~65 | 23 |
| Onboarding-Texte | 4 | 1 |
| ClickUp-Felder | 9 | 1 |
| Sonstige | 9 | 5 |
| Sprach-Listen (evtl. nicht übersetzen) | ~60 | 5 |
| **Total (ohne Sprach-Listen)** | **~241** | **~45 Dateien** |

17
scripts/list_t_errors.py Normal file
View file

@ -0,0 +1,17 @@
import re
from pathlib import Path
text = Path(__file__).resolve().parent.parent / "tsc-out.txt"
content = text.read_text(encoding="utf-8")
files = sorted(
{
m.group(1)
for line in content.splitlines()
if "Cannot find name 't'" in line
for m in [re.match(r"^(src/[^\(:]+\.tsx)", line)]
if m
}
)
for f in files:
print(f)
print("TOTAL", len(files), file=__import__("sys").stderr)

View file

@ -5,7 +5,8 @@
*/
import React from 'react';
import { ACCESS_LEVEL_OPTIONS, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
import { _getAccessLevelOptions, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AccessRules.module.css';
interface AccessLevelSelectProps {
@ -25,6 +26,8 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
showLabel = false,
compact = false,
}) => {
const { t } = useLanguage();
const accessLevelOptions = _getAccessLevelOptions(t);
const currentColor = getAccessLevelColor(value);
return (
@ -42,7 +45,7 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
color: currentColor,
}}
>
{ACCESS_LEVEL_OPTIONS.map(option => (
{accessLevelOptions.map(option => (
<option
key={option.value}
value={option.value}

View file

@ -37,6 +37,8 @@ import { AccessLevelSelect } from './AccessLevelSelect';
import { AccessRulesTable } from './AccessRulesTable';
import styles from './AccessRules.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// =============================================================================
// TYPES
// =============================================================================
@ -66,6 +68,7 @@ interface RuleCardProps {
}
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
const { t } = useLanguage();
const isDataRule = rule.context === 'DATA';
return (
@ -83,7 +86,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title="Regel löschen"
title={t('accessRulesEditor.regelLoeschen')}
>
<FaTrash />
</button>
@ -137,7 +140,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Delete</span>
<span className={styles.permissionLabel}>{t('accessRulesEditor.delete')}</span>
<AccessLevelSelect
value={rule.delete}
onChange={(value) => onUpdate(rule.id, { delete: value })}
@ -167,6 +170,7 @@ interface AddRuleFormProps {
}
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
const { t } = useLanguage();
const [item, setItem] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [view, setView] = useState(true);
@ -217,13 +221,13 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<div className={styles.objectSelectorLabel}>
<label className={styles.formLabel}>Objekt auswählen</label>
<label className={styles.formLabel}>{t('accessRulesEditor.objektAuswaehlen')}</label>
<button
type="button"
className={styles.toggleCustomButton}
onClick={() => setUseCustom(!useCustom)}
>
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
{useCustom ? t('accessRulesEditor.ausKatalogWaehlen') : t('accessRulesEditor.freieEingabe')}
</button>
</div>
@ -242,7 +246,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
onChange={(e) => setItem(e.target.value)}
className={styles.formSelect}
>
<option value="">-- Global (alle Objekte) --</option>
<option value="">{t('accessRulesEditor.globalAlleObjekte')}</option>
{Object.entries(groupedObjects).map(([feature, objs]) => (
<optgroup key={feature} label={feature.toUpperCase()}>
{objs.map(obj => (
@ -277,9 +281,9 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
{/* Header Row */}
<div className={styles.matrixHeader}>
<div className={styles.matrixLabel}></div>
<div className={styles.matrixGroup}>Eigene (m)</div>
<div className={styles.matrixGroup}>Gruppe (g)</div>
<div className={styles.matrixGroup}>Alle (a)</div>
<div className={styles.matrixGroup}>{t('accessRulesEditor.eigeneM')}</div>
<div className={styles.matrixGroup}>{t('accessRulesEditor.gruppeG')}</div>
<div className={styles.matrixGroup}>{t('accessRulesEditor.alleA')}</div>
</div>
{/* CRUD Rows */}
@ -531,6 +535,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
featureCode,
}) => {
const { showError } = useToast();
const { t } = useLanguage();
const {
rules,
loading,
@ -628,9 +633,9 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
// Render tabs
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length },
{ id: 'DATA', label: t('accessRulesEditor.daten'), icon: <FaTable />, count: groupedRules.DATA.length },
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length },
{ id: 'RESOURCE', label: t('accessRulesEditor.ressourcen'), icon: <FaServer />, count: groupedRules.RESOURCE.length },
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
];
@ -639,7 +644,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
<div className={styles.accessRulesEditor}>
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Berechtigungen...</span>
<span>{t('accessRulesEditor.ladeBerechtigungen')}</span>
</div>
</div>
);

View file

@ -10,6 +10,8 @@ import { FaTable, FaDesktop, FaServer, FaTrash } from 'react-icons/fa';
import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
import styles from './AccessRules.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// =============================================================================
// TYPES
// =============================================================================
@ -74,6 +76,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
onUpdate,
onDelete,
}) => {
const { t } = useLanguage();
const handleLevelToggle = (
field: 'read' | 'create' | 'update' | 'delete',
targetLevel: 'm' | 'g' | 'a',
@ -163,7 +166,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title="Regel löschen"
title={t('accessRulesTable.regelLoeschen')}
>
<FaTrash />
</button>
@ -184,6 +187,7 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
onUpdate,
onDelete,
}) => {
const { t } = useLanguage();
const isDataContext = context === 'DATA';
if (rules.length === 0) {
@ -195,13 +199,13 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<table className={styles.accessRulesTable}>
<thead>
<tr>
<th className={styles.colObject}>Objekt (Dot-Notation)</th>
<th className={styles.colObject}>{t('accessRulesTable.objektDotnotation')}</th>
<th className={styles.colView}>View</th>
{isDataContext && (
<>
<th className={styles.colGroupHeader} colSpan={4}>Eigene (m)</th>
<th className={styles.colGroupHeader} colSpan={4}>Gruppe (g)</th>
<th className={styles.colGroupHeader} colSpan={4}>Alle (a)</th>
<th className={styles.colGroupHeader} colSpan={4}>{t('accessRulesTable.eigeneM')}</th>
<th className={styles.colGroupHeader} colSpan={4}>{t('accessRulesTable.gruppeG')}</th>
<th className={styles.colGroupHeader} colSpan={4}>{t('accessRulesTable.alleA')}</th>
</>
)}
<th className={styles.colActions}></th>
@ -213,15 +217,15 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title="Delete">D</th>
<th title={t('accessRulesTable.delete')}>D</th>
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title="Delete">D</th>
<th title={t('accessRulesTable.delete')}>D</th>
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title="Delete">D</th>
<th title={t('accessRulesTable.delete')}>D</th>
<th></th>
</tr>
)}

View file

@ -61,11 +61,11 @@ export function ContentPreview({
if (isOpen && fileId) {
// Check if we have valid data
if (!fileId || fileId === 'undefined' || fileId === 'null') {
setError('Invalid file ID');
setError(t('contentPreview.invalidFileId'));
return;
}
if (!fileName || fileName === 'Unknown Item') {
setError('File name not available');
setError(t('contentPreview.fileNameNotAvailable'));
return;
}
loadPreview();
@ -98,7 +98,7 @@ export function ContentPreview({
setError(result.error || 'Failed to load preview');
}
} catch (err) {
setError('An unexpected error occurred while loading the preview');
setError(t('contentPreview.anUnexpectedErrorOccurredWhile'));
}
};
@ -168,7 +168,7 @@ export function ContentPreview({
previewUrl={undefined}
previewContent={previewContent}
fileName={fileName}
onError={() => setError('Failed to load PDF preview')}
onError={() => setError(t('contentPreview.failedToLoadPdfPreview'))}
/>
);
}
@ -194,9 +194,9 @@ export function ContentPreview({
return (
<div className={styles.jsonContainer}>
<div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>JSON Preview (Fallback)</span>
<span className={styles.jsonTitle}>{t('contentPreview.jsonPreviewFallback')}</span>
<div className={styles.jsonHeaderRight}>
<span className={styles.jsonSize}>Raw content</span>
<span className={styles.jsonSize}>{t('contentPreview.rawContent')}</span>
</div>
</div>
<pre className={styles.jsonPreview}>
@ -219,7 +219,7 @@ export function ContentPreview({
<ImageRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load image preview')}
onError={() => setError(t('contentPreview.failedToLoadImagePreview'))}
/>
);
@ -230,7 +230,7 @@ export function ContentPreview({
<HtmlRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load HTML preview')}
onError={() => setError(t('contentPreview.failedToLoadHtmlPreview'))}
/>
);
}
@ -240,7 +240,7 @@ export function ContentPreview({
previewUrl={previewUrl}
fileName={fileName}
mimeType={mimeType}
onError={() => setError('Failed to load text preview')}
onError={() => setError(t('contentPreview.failedToLoadTextPreview'))}
/>
);
@ -257,7 +257,7 @@ export function ContentPreview({
previewUrl={previewUrl}
previewContent={previewContent || undefined}
fileName={fileName}
onError={() => setError('Failed to load PDF preview')}
onError={() => setError(t('contentPreview.failedToLoadPdfPreview'))}
/>
);
}
@ -267,7 +267,7 @@ export function ContentPreview({
<HtmlRenderer
previewUrl={previewUrl}
fileName={fileName}
onError={() => setError('Failed to load HTML preview')}
onError={() => setError(t('contentPreview.failedToLoadHtmlPreview'))}
/>
);
}
@ -279,7 +279,7 @@ export function ContentPreview({
previewUrl={previewUrl}
fileName={fileName}
mimeType={mimeType}
onError={() => setError('Preview not supported for this file type')}
onError={() => setError(t('contentPreview.previewNotSupportedForThis'))}
/>
);

View file

@ -79,7 +79,7 @@ export function UrlContentPreview({
}
// If PDF.js also fails, show error
setIsLoading(false);
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.');
setError(t('urlContentPreview.failedToLoadPdfThis'));
setShowPdfAnyway(true);
};
@ -111,7 +111,7 @@ export function UrlContentPreview({
} else if (isLoading && !hasLoaded && usePdfJs) {
// PDF.js also failed, show error
setShowPdfAnyway(true);
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.');
setError(t('urlContentPreview.pdfLaedtLangsamBitteVerwenden'));
setIsLoading(false);
}
}, QUICK_TIMEOUT);
@ -129,7 +129,7 @@ export function UrlContentPreview({
try {
new URL(url);
} catch (e) {
setError('Invalid URL');
setError(t('urlContentPreview.invalidUrl'));
setIsLoading(false);
}
}
@ -314,7 +314,7 @@ export function UrlContentPreview({
<div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div>
<div className={styles.fileName}>{fileName}</div>
<p>Preview not supported for this file type. Please download the file to view it.</p>
<p>{t('urlContentPreview.previewNotSupportedForThis')}</p>
<button onClick={handleDownload} className={styles.retryButton}>
Download File
</button>

View file

@ -339,7 +339,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
) : (
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
renderTable(data.values[index], level + 1, rowPath) :
<span className={styles.jsonValue}>Error: Invalid nested data</span>
<span className={styles.jsonValue}>{t('jsonRenderer.errorInvalidNestedData')}</span>
)
)}
</div>

View file

@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Set worker source for PDF.js
if (typeof window !== 'undefined') {
// Try to use local worker first, fallback to CDN
@ -24,7 +26,9 @@ interface PdfJsRendererProps {
onLoad?: () => void;
}
export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
export function PdfJsRenderer({
previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
const { t } = useLanguage();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
@ -137,7 +141,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
return (
<div className={styles.loadingContainer}>
<div className={styles.spinner}></div>
<p>PDF wird geladen...</p>
<p>{t('pdfJsRenderer.pdfWirdGeladen')}</p>
</div>
);
}

View file

@ -1,5 +1,7 @@
import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Updated to handle both previewUrl and previewContent
interface TextRendererProps {
@ -10,13 +12,15 @@ interface TextRendererProps {
onError: () => void;
}
export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
export function TextRenderer({
previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
const { t } = useLanguage();
// If we have previewContent directly, display it as text
if (previewContent && !previewUrl) {
return (
<div className={styles.textContainer}>
<div className={styles.textHeader}>
<span className={styles.textTitle}>Text Preview</span>
<span className={styles.textTitle}>{t('textRenderer.textPreview')}</span>
</div>
<pre className={styles.textPreview}>
<code className={styles.textCode}>

View file

@ -53,6 +53,8 @@ import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[Automation2]';
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
@ -72,8 +74,7 @@ interface Automation2FlowEditorProps {
onSourcesChanged?: () => void;
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
instanceId,
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
mandateId,
language = 'de',
initialWorkflowId,
@ -84,6 +85,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onFileSelect,
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
@ -235,7 +237,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = await promptInput('Workflow-Name:', {
title: 'Workflow speichern',
title: t('automation2FlowEditor.saveWorkflow'),
defaultValue: 'Neuer Workflow',
placeholder: 'Name des Workflows',
});
@ -593,7 +595,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
<p>Lade Node-Typen...</p>
<p>{t('automation2FlowEditor.ladeNodetypen')}</p>
</div>
</div>
);

View file

@ -7,6 +7,8 @@ import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchi
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
@ -34,14 +36,15 @@ interface CanvasHeaderProps {
onWorkflowRename?: (workflowId: string, newName: string) => void;
}
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
draft: { label: 'Entwurf', color: 'var(--warning-color, #ffc107)' },
published: { label: 'Veröffentlicht', color: 'var(--success-color, #28a745)' },
archived: { label: 'Archiviert', color: 'var(--text-secondary, #666)' },
};
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
return {
draft: { label: t('canvasHeader.entwurf'), color: 'var(--warning-color, #ffc107)' },
published: { label: t('canvasHeader.veroeffentlicht'), color: 'var(--success-color, #28a745)' },
archived: { label: t('canvasHeader.archiviert'), color: 'var(--text-secondary, #666)' },
};
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
@ -66,9 +69,11 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
onNewFromTemplate,
onWorkflowRename,
}) => {
const { t } = useLanguage();
const statusBadge = _getStatusBadge(t);
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.draft;
const badge = statusBadge[currentStatus] || statusBadge.draft;
const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef<HTMLDivElement>(null);
@ -148,7 +153,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
<button
type="button"
className={styles.canvasGearBtn}
title="Workflow-Konfiguration (Einstieg / Starts)"
title={t('canvasHeader.workflowkonfigurationEinstiegStarts')}
aria-label="Workflow-Konfiguration"
onClick={onWorkflowSettings}
>
@ -167,7 +172,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={() => setNewMenuOpen((p) => !p)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
title="Neu aus Vorlage"
title={t('canvasHeader.neuAusVorlage')}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
@ -211,9 +216,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
disabled={templateSaving}
title="Als Vorlage speichern"
title={t('canvasHeader.alsVorlageSpeichern')}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />Als Vorlage</>}
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('canvasHeader.alsVorlage')}</>}
</button>
{templateMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
@ -239,7 +244,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value=""> Workflow laden </option>
<option value="">{t('canvasHeader.workflowLaden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
@ -265,7 +270,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="Workspace-Panel (Chats, Dateien, Quellen)">
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('canvasHeader.workspacepanelChatsDateienQuellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
Workspace
</button>
@ -282,10 +287,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading}
>
<option value=""> Aktuelle </option>
<option value="">{t('canvasHeader.aktuelle')}</option>
{versions.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} ({STATUS_BADGE[v.status]?.label ?? v.status})
v{v.versionNumber} ({statusBadge[v.status]?.label ?? v.status})
</option>
))}
</select>
@ -307,7 +312,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title="Version veröffentlichen"
title={t('canvasHeader.versionVeroeffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
@ -320,7 +325,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title="Veröffentlichung zurücknehmen"
title={t('canvasHeader.veroeffentlichungZuruecknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
@ -333,7 +338,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title="Version archivieren"
title={t('canvasHeader.versionArchivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaArchive style={{ marginRight: 4 }} />
@ -346,7 +351,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
className={styles.retryButton}
onClick={onCreateDraft}
disabled={versionLoading}
title="Neuen Entwurf erstellen"
title={t('canvasHeader.neuenEntwurfErstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
+ Entwurf
@ -376,10 +381,10 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
}}
>
{executeResult.success ? (
<> Ausführung abgeschlossen.</>
<>{t('canvasHeader.ausfuehrungAbgeschlossen')}</>
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den
Workflow pausiert. Öffne <strong>{t('canvasHeader.workflowsTasks')}</strong> in der Sidebar, um den
Task zu bearbeiten.
</>
) : (

View file

@ -13,6 +13,8 @@ import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat';
import { getPageIcon } from '../../../config/pageRegistry';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile {
fileId: string;
fileName: string;
@ -46,8 +48,7 @@ interface EditorChatPanelProps {
let _msgCounter = 0;
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
instanceId,
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
workflowId,
onGraphUpdated,
pendingFiles = [],
@ -55,6 +56,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
dataSources = [],
featureDataSources = [],
}) => {
const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState('');
@ -187,7 +189,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
<ChatMessageList
messages={messages}
isProcessing={loading}
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
emptyMessage={t('editorChatPanel.describeWhatYouWantTo')}
/>
{/* Pending files (from UDB drag/click) */}
@ -277,7 +279,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={workflowId ? 'Describe a change...' : 'Save workflow first'}
placeholder={workflowId ? t('editorChatPanel.describeAChange') : t('editorChatPanel.saveWorkflowFirst')}
disabled={!workflowId || loading}
style={{
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
@ -294,7 +296,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={loading || !workflowId}
title="Datenquellen anhängen"
title={t('editorChatPanel.datenquellenAnhaengen')}
style={{
width: 36, height: 36, borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
@ -399,7 +401,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
fontWeight: 600, fontSize: 12,
}}>Send</button>
}}>{t('editorChatPanel.send')}</button>
)}
</div>
</div>

View file

@ -7,6 +7,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface CanvasNode {
id: string;
type: string;
@ -78,8 +80,7 @@ const HIGHLIGHT_COLORS: Record<string, string> = {
skipped: '#6c757d',
};
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
nodes,
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
connections,
nodeTypes,
onNodesChange,
@ -90,6 +91,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
onSelectionChange,
highlightedNodeIds,
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
@ -638,7 +640,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
style={{ cursor: 'pointer' }}
role="button"
tabIndex={-1}
aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)"
aria-label={t('flowCanvas.verbindungAuswaehlenEntfZumLoeschen')}
>
<path
d={pathD}
@ -657,7 +659,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
pointerEvents="none"
/>
{isWarning && !isSelected && (
<title>Type mismatch warning: output type may not match input type</title>
<title>{t('flowCanvas.typeMismatchWarningOutputType')}</title>
)}
</g>
);
@ -756,8 +758,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
outputLabel ??
(selectedConnectionId && !isOutput
? used
? 'Aktuelles Ziel klicken zum Abwählen'
: 'Klicken zum Umleiten'
? t('flowCanvas.aktuellesZielKlickenZumAbwaehlen')
: t('flowCanvas.klickenZumUmleiten')
: undefined)
}
/>
@ -836,7 +838,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
)}
{nodes.length === 0 && (
<div className={styles.canvasPlaceholder}>
<p>Nodes aus der Liste links hierher ziehen.</p>
<p>{t('flowCanvas.nodesAusDerListeLinks')}</p>
</div>
)}
</div>

View file

@ -11,6 +11,8 @@ import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface NodeConfigPanelProps {
node: CanvasNode | null;
nodeType: NodeType | undefined;
@ -22,16 +24,16 @@ interface NodeConfigPanelProps {
request?: ApiRequestFunction;
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node,
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
nodeType,
language,
onParametersChange,
onMergeNodeParameters,
onMergeNodeParameters: _onMergeNodeParameters,
onNodeUpdate,
instanceId,
request,
}) => {
const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({});
const nodeIdRef = useRef<string | undefined>(undefined);
nodeIdRef.current = node?.id;
@ -80,13 +82,13 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
<div className={styles.nodeConfigPanel}>
{showNameField && (
<div className={styles.nodeConfigNameRow}>
<label htmlFor="node-config-name">Bezeichnung</label>
<label htmlFor="node-config-name">{t('nodeConfigPanel.bezeichnung')}</label>
<input
id="node-config-name"
type="text"
value={node.title ?? ''}
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
placeholder="z.B. Kundenformular, Prüfen Land"
placeholder={t('nodeConfigPanel.zbKundenformularPruefenLand')}
/>
<p className={styles.nodeConfigNameHint}>
Wird im Data Picker angezeigt, um diesen Node zu identifizieren.

View file

@ -11,6 +11,8 @@ import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface NodeSidebarProps {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
@ -24,8 +26,7 @@ interface NodeSidebarProps {
style?: React.CSSProperties;
}
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
nodeTypes,
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
categories,
filter,
onFilterChange,
@ -35,6 +36,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
excludedCategories,
style,
}) => {
const { t } = useLanguage();
const filteredNodeTypes = useMemo(() => {
const visible = nodeTypes.filter(
(n) =>
@ -76,8 +78,8 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
return result;
}, [groupedByCategory]);
const getLabelFn = (t: string | Record<string, string> | undefined, lang?: string) =>
getLabel(t, lang ?? language);
const getLabelFn = (multilingual: string | Record<string, string> | undefined, lang?: string) =>
getLabel(multilingual, lang ?? language);
return (
<div className={styles.sidebar} style={style}>
@ -86,7 +88,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
<input
type="text"
className={styles.sidebarSearch}
placeholder="Nodes durchsuchen..."
placeholder={t('nodeSidebar.nodesDurchsuchen')}
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
/>

View file

@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface RunTracingPanelProps {
instanceId: string;
runId: string | null;
@ -49,7 +51,9 @@ function _truncateJson(obj: unknown, maxLen = 300): string {
}
}
const CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => {
const CollapsibleSection: React.FC<{
label: string; content: string;
}> = ({ label, content }) => {
const [open, setOpen] = useState(false);
if (!content) return null;
return (
@ -82,6 +86,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
onNodeSelect,
onActiveStepsChange,
}) => {
const { t } = useLanguage();
const [steps, setSteps] = useState<AutoStepLog[]>([]);
const [loading, setLoading] = useState(false);
const [sseConnected, setSseConnected] = useState(false);
@ -178,7 +183,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
Run Steps {loading && <span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>(loading...)</span>}
</div>
{steps.length === 0 && !loading && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>No steps recorded yet.</div>
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>{t('runTracingPanel.noStepsRecordedYet')}</div>
)}
{steps.map((step: any) => {
const startStr = _formatTimestamp(step.startedAt);
@ -217,7 +222,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
</span>
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{step.retryCount > 0 && (
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title="Retry count">
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title={t('runTracingPanel.retryCount')}>
{step.retryCount}x retry
</span>
)}

View file

@ -10,13 +10,17 @@ import {
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
const KIND_OPTIONS: { value: string; label: string }[] = [
{ value: 'manual', label: 'Manueller Trigger' },
{ value: 'form', label: 'Formular' },
{ value: 'schedule', label: 'Zeitplan' },
{ value: 'always_on', label: 'Immer aktiv' },
];
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
return [
{ value: 'manual', label: t('workflowConfigurationModal.manuellerTrigger') },
{ value: 'form', label: t('workflowConfigurationModal.formular') },
{ value: 'schedule', label: t('workflowConfigurationModal.zeitplan') },
{ value: 'always_on', label: t('workflowConfigurationModal.immerAktiv') },
];
}
interface WorkflowConfigurationModalProps {
open: boolean;
@ -25,19 +29,22 @@ interface WorkflowConfigurationModalProps {
onApply: (next: WorkflowEntryPoint[]) => void;
}
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
function normalizeLoadedKind(k: string): string {
if (KIND_OPTIONS.some((o) => o.value === k)) return k;
if (_validKinds.includes(k)) return k;
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
if (k === 'api') return 'manual';
return 'manual';
}
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({
open,
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
onClose,
invocations,
onApply,
}) => {
const { t } = useLanguage();
const kindOptions = _getKindOptions(t);
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
const [titleDe, setTitleDe] = useState('');
@ -46,9 +53,9 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
setKind(k);
const entry = invocations[0];
const t = entry?.title;
if (typeof t === 'string') setTitleDe(t);
else if (t && typeof t === 'object') setTitleDe(t.de || t.en || '');
const entryTitle = entry?.title;
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
else setTitleDe('');
}, [open, invocations]);
@ -57,7 +64,7 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || KIND_OPTIONS.find((o) => o.value === kind)?.label || 'Start';
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || 'Start';
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
@ -82,11 +89,11 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder="z. B. Angebot anlegen"
placeholder={t('workflowConfigurationModal.zBAngebotAnlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label="Einstiegsart">
{KIND_OPTIONS.map((o) => (
{kindOptions.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
type="radio"

View file

@ -5,23 +5,28 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>Beschreibung</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder="Was genehmigt werden soll"
/>
</div>
</>
);
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>{t('approvalNodeConfig.beschreibung')}</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder={t('approvalNodeConfig.wasGenehmigtWerdenSoll')}
/>
</div>
</>
);
};

View file

@ -31,6 +31,8 @@ import {
} from '../shared/clickupFormSync';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
/** ClickUp browse: list folder paths end with /list/{id} */
const LIST_SEGMENT_RE = /\/list\/([^/]+)$/;
@ -352,6 +354,7 @@ function ClickUpListRelationshipFieldRow({
value: unknown;
onChange: (v: unknown) => void;
}) {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
@ -399,7 +402,7 @@ function ClickUpListRelationshipFieldRow({
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
staticLabel="Statisch (Aufgabe wählen)"
/>
</div>
@ -419,7 +422,7 @@ function ClickUpListRelationshipFieldRow({
else onChange(createValue({ add: [tid], rem: [] }));
}}
>
<option value=""> Aufgabe wählen </option>
<option value="">{t('clickUpNodeConfig.aufgabeWaehlen')}</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
@ -472,6 +475,7 @@ function ClickUpCustomFieldRow({
/** List where the new task is created — fallback if relationship omits linked list (same-list). */
parentListId?: string;
}) {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const fid = String(field.id ?? '');
const fname = String(field.name ?? fid);
@ -539,7 +543,7 @@ function ClickUpCustomFieldRow({
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
/>
</div>
) : null}
@ -552,7 +556,7 @@ function ClickUpCustomFieldRow({
else onChange(createValue(v));
}}
>
<option value=""> Option wählen </option>
<option value="">{t('clickUpNodeConfig.optionWaehlen')}</option>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
@ -574,7 +578,7 @@ function ClickUpCustomFieldRow({
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
/>
</div>
) : null}
@ -610,7 +614,7 @@ function ClickUpCustomFieldRow({
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
/>
</div>
) : null}
@ -670,6 +674,7 @@ function ClickUpTaskDueDateRow({
value: unknown;
onChange: (v: unknown) => void;
}) {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const hasSources =
dataFlow &&
@ -685,13 +690,13 @@ function ClickUpTaskDueDateRow({
const day = ms > 0 ? new Date(ms).toISOString().slice(0, 10) : '';
return (
<div className={styles.dynamicValueField}>
<label>Fälligkeit</label>
<label>{t('clickUpNodeConfig.faelligkeit')}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
/>
</div>
) : null}
@ -700,8 +705,8 @@ function ClickUpTaskDueDateRow({
type="date"
value={day}
onChange={(e) => {
const t = e.target.value ? new Date(e.target.value).getTime() : '';
onChange(createValue(t === '' ? '' : t));
const msFromDate = e.target.value ? new Date(e.target.value).getTime() : '';
onChange(createValue(msFromDate === '' ? '' : msFromDate));
}}
/>
)}
@ -716,6 +721,7 @@ function ClickUpTimeEstimateHoursRow({
value: unknown;
onChange: (v: unknown) => void;
}) {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const hasSources =
dataFlow &&
@ -731,13 +737,13 @@ function ClickUpTimeEstimateHoursRow({
const hours = h > 0 && Number.isFinite(h) ? h : '';
return (
<div className={styles.dynamicValueField}>
<label>Zeitschätzung (Stunden)</label>
<label>{t('clickUpNodeConfig.zeitschaetzungStunden')}</label>
{hasSources ? (
<div style={{ marginBottom: 8 }}>
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('clickUpNodeConfig.quelleWaehlen')}
/>
</div>
) : null}
@ -778,17 +784,19 @@ function newClickUpTaskUpdateEntryRow(): ClickUpTaskUpdateEntryRow {
};
}
const UPDATE_TASK_FIELD_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'name', label: 'Titel (name)' },
{ value: 'description', label: 'Beschreibung' },
{ value: 'status', label: 'Status' },
{ value: 'priority', label: 'Priorität (14)' },
{ value: 'due_date', label: 'Fälligkeit (Datum oder ms)' },
{ value: 'time_estimate_h', label: 'Zeitschätzung (Stunden)' },
{ value: 'time_estimate_ms', label: 'Zeitschätzung (ms)' },
{ value: 'assignees', label: 'Zugewiesene' },
{ value: 'custom_field', label: 'Benutzerdefiniertes Feld' },
];
function _getUpdateTaskFieldOptions(t: (key: string) => string): Array<{ value: string; label: string }> {
return [
{ value: 'name', label: t('clickUpNodeConfig.titleName') },
{ value: 'description', label: t('clickUpNodeConfig.description') },
{ value: 'status', label: t('clickUpNodeConfig.status') },
{ value: 'priority', label: t('clickUpNodeConfig.priority') },
{ value: 'due_date', label: t('clickUpNodeConfig.dueDate') },
{ value: 'time_estimate_h', label: t('clickUpNodeConfig.timeEstimateHours') },
{ value: 'time_estimate_ms', label: t('clickUpNodeConfig.timeEstimateMs') },
{ value: 'assignees', label: t('clickUpNodeConfig.assignees') },
{ value: 'custom_field', label: t('clickUpNodeConfig.customField') },
];
}
function normalizeTaskUpdateEntries(raw: unknown): ClickUpTaskUpdateEntryRow[] {
if (!Array.isArray(raw)) return [];
@ -828,6 +836,7 @@ function ClickUpTaskFromListDropdown({
taskId: unknown;
onSetTaskId: (v: unknown) => void;
}) {
const { t } = useLanguage();
const [tasks, setTasks] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
@ -864,12 +873,12 @@ function ClickUpTaskFromListDropdown({
return (
<div className={styles.dynamicValueField} style={{ marginTop: 10 }}>
<label>Vorhandene Aufgabe in der gewählten Liste</label>
<label>{t('clickUpNodeConfig.vorhandeneAufgabeInDerGewaehlten')}</label>
{err ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #c00)' }}>{err}</p>
) : null}
{loading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Aufgaben werden geladen</span>
<span style={{ fontSize: '0.85rem', color: '#666' }}>{t('clickUpNodeConfig.aufgabenWerdenGeladen')}</span>
) : (
<select
value={sel}
@ -879,10 +888,10 @@ function ClickUpTaskFromListDropdown({
else onSetTaskId(createValue(id));
}}
>
<option value=""> Aufgabe aus ClickUp wählen </option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name || t.id}
<option value="">{t('clickUpNodeConfig.aufgabeAusClickupWaehlen')}</option>
{tasks.map((task) => (
<option key={task.id} value={task.id}>
{task.name || task.id}
</option>
))}
</select>
@ -923,12 +932,15 @@ function ClickUpUpdateTaskEntriesEditor({
parentListStatuses: Array<{ status: string; orderindex: number }>;
parentListStatusesLoading: boolean;
}) {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [resolvedStatuses, setResolvedStatuses] = useState<Array<{ status: string; orderindex: number }>>(
[]
);
const [resolvedStatusesLoading, setResolvedStatusesLoading] = useState(false);
const updateTaskFieldOptions = useMemo(() => _getUpdateTaskFieldOptions(t), [t]);
const previewFingerprint = useMemo(
() => JSON.stringify(dataFlow?.nodeOutputsPreview ?? {}),
[dataFlow?.nodeOutputsPreview]
@ -1044,7 +1056,7 @@ function ClickUpUpdateTaskEntriesEditor({
const fk = row.fieldKey;
const commonHybrid = (multiline?: boolean, placeholder?: string, inputType?: 'text' | 'number') => (
<HybridStaticRefField
label="Oder Referenz (ohne Formular-Payload)"
label={t('clickUpNodeConfig.oderReferenzOhneFormularpayload')}
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline={multiline}
@ -1065,7 +1077,7 @@ function ClickUpUpdateTaskEntriesEditor({
pathPickMode="exclude_forms"
/>
<HybridStaticRefField
label="Neuer Wert"
label={t('clickUpNodeConfig.neuerWert')}
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline
@ -1080,10 +1092,10 @@ function ClickUpUpdateTaskEntriesEditor({
if (isRef(row.value)) {
return (
<HybridStaticRefField
label="Status (Referenz)"
label={t('clickUpNodeConfig.statusReferenz')}
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
placeholder="Kontext mit Status-String"
placeholder={t('clickUpNodeConfig.kontextMitStatusstring')}
pathPickMode="exclude_forms"
/>
);
@ -1094,16 +1106,16 @@ function ClickUpUpdateTaskEntriesEditor({
if (statusOptionsLoading) {
return (
<div className={styles.dynamicValueField}>
<label>Status (wie in ClickUp)</label>
<label>{t('clickUpNodeConfig.statusWieInClickup')}</label>
<select disabled value="">
<option value="">Status werden geladen</option>
<option value="">{t('clickUpNodeConfig.statusWerdenGeladen')}</option>
</select>
</div>
);
}
return (
<div className={styles.dynamicValueField}>
<label>Status (wie in ClickUp)</label>
<label>{t('clickUpNodeConfig.statusWieInClickup')}</label>
<select
value={staticVal}
onChange={(e) => updateRow(row.rowId, { value: createValue(e.target.value) })}
@ -1111,8 +1123,8 @@ function ClickUpUpdateTaskEntriesEditor({
>
<option value="">
{statusOptions.length === 0
? '— Keine Status geladen —'
: '— Status wählen —'}
? t('clickUpNodeConfig.keineStatusGeladen')
: t('clickUpNodeConfig.statusWaehlen')}
</option>
{statusOptions.map((s) => (
<option key={s.status} value={s.status}>
@ -1144,16 +1156,16 @@ function ClickUpUpdateTaskEntriesEditor({
const pv = isRef(row.value) ? '' : isValue(row.value) ? String(row.value.value ?? '') : String(row.value ?? '');
return (
<div className={styles.dynamicValueField}>
<label>Priorität</label>
<label>{t('clickUpNodeConfig.prioritaet')}</label>
<select
value={pv && ['1', '2', '3', '4'].includes(pv) ? pv : ''}
onChange={(e) => updateRow(row.rowId, { value: createValue(e.target.value) })}
>
<option value=""> wählen </option>
<option value="1">1 Dringend</option>
<option value="2">2 Hoch</option>
<option value="3">3 Normal</option>
<option value="4">4 Niedrig</option>
<option value="">{t('clickUpNodeConfig.waehlen')}</option>
<option value="1">{t('clickUpNodeConfig.1Dringend')}</option>
<option value="2">{t('clickUpNodeConfig.2Hoch')}</option>
<option value="3">{t('clickUpNodeConfig.3Normal')}</option>
<option value="4">{t('clickUpNodeConfig.4Niedrig')}</option>
</select>
</div>
);
@ -1192,7 +1204,7 @@ function ClickUpUpdateTaskEntriesEditor({
return (
<>
{teamMembersLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Mitglieder werden geladen</span>
<span style={{ fontSize: '0.85rem', color: '#666' }}>{t('clickUpNodeConfig.mitgliederWerdenGeladen')}</span>
) : teamMembers.length === 0 ? (
<p style={{ fontSize: '0.8rem', color: '#666' }}>
Keine Mitglieder Workspace oben wählen oder JSON / Referenz unten.
@ -1243,7 +1255,7 @@ function ClickUpUpdateTaskEntriesEditor({
value={row.value}
onChange={(v) => updateRow(row.rowId, { value: v })}
multiline
placeholder="Beschreibung"
placeholder={t('clickUpNodeConfig.beschreibung')}
pathPickMode="exclude_forms"
/>
);
@ -1304,7 +1316,7 @@ function ClickUpUpdateTaskEntriesEditor({
})
}
>
{UPDATE_TASK_FIELD_OPTIONS.map((o) => (
{updateTaskFieldOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@ -1336,6 +1348,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
mergeNodeParameters,
nodeType = 'clickup.listTasks',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const lastFormSyncSigRef = useRef<string>('');
const [connections, setConnections] = useState<UserConnection[]>([]);
@ -1651,13 +1664,13 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
return (
<>
<div>
<label>Connection (ClickUp)</label>
<label>{t('clickUpNodeConfig.connectionClickup')}</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
<option value="">{connectionsLoading ? t('clickUpNodeConfig.loading') : t('clickUpNodeConfig.selectConnection')}</option>
{clickupConnections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
@ -1680,7 +1693,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
{searchTeamsLoading ? t('clickUpNodeConfig.workspacesWerdenGeladen') : t('clickUpNodeConfig.workspaceWaehlen')}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
@ -1690,7 +1703,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
</select>
</div>
<div>
<label>Liste (Tabelle)</label>
<label>{t('clickUpNodeConfig.listeTabelle')}</label>
<select
value={listIdParam}
onChange={(e) => updateParam('listId', e.target.value)}
@ -1700,8 +1713,8 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
{searchListsLoading
? 'Listen werden geladen…'
: teamIdParam
? 'Alle Listen im Workspace'
: 'Zuerst Workspace wählen'}
? t('clickUpNodeConfig.alleListenImWorkspace')
: t('clickUpNodeConfig.zuerstWorkspaceWaehlen')}
</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
@ -1715,7 +1728,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder="Stichwort für die Aufgabensuche"
placeholder={t('clickUpNodeConfig.stichwortFuerDieAufgabensuche')}
/>
</div>
<details style={{ marginTop: 8 }}>
@ -1723,7 +1736,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
Erweitert
</summary>
<div style={{ marginTop: 8 }}>
<label>Seite (Pagination)</label>
<label>{t('clickUpNodeConfig.seitePagination')}</label>
<input
type="number"
min={0}
@ -1751,7 +1764,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
checked={Boolean(params.includeClosed)}
onChange={(e) => updateParam('includeClosed', e.target.checked)}
/>
<span>Erledigte Aufgaben einbeziehen (Listen-Suche)</span>
<span>{t('clickUpNodeConfig.erledigteAufgabenEinbeziehenListensuche')}</span>
</label>
</div>
) : null}
@ -1790,7 +1803,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
{searchTeamsLoading ? t('clickUpNodeConfig.workspacesWerdenGeladen') : t('clickUpNodeConfig.workspaceWaehlen')}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
@ -1816,7 +1829,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
}}
disabled={!teamIdParam || searchListsLoading}
>
<option value="">{searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}</option>
<option value="">{searchListsLoading ? t('clickUpNodeConfig.listenWerdenGeladen') : t('clickUpNodeConfig.listeWaehlen')}</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
{L.name}
@ -1851,7 +1864,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
checked={params.autoSyncFormWithList !== false}
onChange={(e) => updateParam('autoSyncFormWithList', e.target.checked)}
/>
<span>Bei Listenwahl automatisch abgleichen</span>
<span>{t('clickUpNodeConfig.beiListenwahlAutomatischAbgleichen')}</span>
</label>
<button
type="button"
@ -1887,15 +1900,15 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
</p>
<div className={styles.dynamicValueField}>
<label>Status</label>
<label>{t('clickUpNodeConfig.status')}</label>
{listStatusesLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Status wird geladen</span>
<span style={{ fontSize: '0.85rem', color: '#666' }}>{t('clickUpNodeConfig.statusWirdGeladen')}</span>
) : (
<select
value={(params.taskStatus as string) ?? ''}
onChange={(e) => updateParam('taskStatus', e.target.value)}
>
<option value=""> Standard (ClickUp) </option>
<option value="">{t('clickUpNodeConfig.standardClickup')}</option>
{listStatuses.map((s) => (
<option key={s.status} value={s.status}>
{s.status}
@ -1906,16 +1919,16 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
</div>
<div className={styles.dynamicValueField} style={{ marginTop: 8 }}>
<label>Priorität</label>
<label>{t('clickUpNodeConfig.prioritaet')}</label>
<select
value={(params.taskPriority as string) ?? ''}
onChange={(e) => updateParam('taskPriority', e.target.value)}
>
<option value=""> keine </option>
<option value="1">1 Dringend</option>
<option value="2">2 Hoch</option>
<option value="3">3 Normal</option>
<option value="4">4 Niedrig</option>
<option value="">{t('clickUpNodeConfig.keine')}</option>
<option value="1">{t('clickUpNodeConfig.1Dringend')}</option>
<option value="2">{t('clickUpNodeConfig.2Hoch')}</option>
<option value="3">{t('clickUpNodeConfig.3Normal')}</option>
<option value="4">{t('clickUpNodeConfig.4Niedrig')}</option>
</select>
</div>
@ -1929,9 +1942,9 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
<div className={styles.dynamicValueField} style={{ marginTop: 8 }}>
<label>Zugewiesene</label>
{teamMembersLoading ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Mitglieder werden geladen</span>
<span style={{ fontSize: '0.85rem', color: '#666' }}>{t('clickUpNodeConfig.mitgliederWerdenGeladen')}</span>
) : teamMembers.length === 0 ? (
<span style={{ fontSize: '0.85rem', color: '#666' }}>Keine Mitglieder geladen.</span>
<span style={{ fontSize: '0.85rem', color: '#666' }}>{t('clickUpNodeConfig.keineMitgliederGeladen')}</span>
) : (
<div
style={{
@ -1978,9 +1991,9 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
{listIdParam ? (
<div style={{ marginTop: 12 }}>
<label style={{ fontWeight: 600 }}>Felder der Liste</label>
<label style={{ fontWeight: 600 }}>{t('clickUpNodeConfig.felderDerListe')}</label>
{listFieldsLoading ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>Felder werden geladen</p>
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>{t('clickUpNodeConfig.felderWerdenGeladen')}</p>
) : listFields.length === 0 ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Keine benutzerdefinierten Felder oder keine Berechtigung.
@ -2010,7 +2023,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
Erweitert (JSON)
</summary>
<div style={{ marginTop: 8 }}>
<label>Zusätzliche Felder (JSON)</label>
<label>{t('clickUpNodeConfig.zusaetzlicheFelderJson')}</label>
<textarea
value={(params.taskFields as string) ?? ''}
onChange={(e) => updateParam('taskFields', e.target.value)}
@ -2025,7 +2038,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
{nodeType === 'clickup.listTasks' && (
<>
<div>
<label>List path</label>
<label>{t('clickUpNodeConfig.listPath')}</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
@ -2066,11 +2079,11 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
label="Task-ID"
value={params.taskId}
onChange={(v) => updateParam('taskId', v)}
placeholder="Referenz: voriger Knoten „Aufgabe erstellen“ → taskId"
placeholder={t('clickUpNodeConfig.referenzVorigerKnotenAufgabeErstellen')}
pathPickMode={nodeType === 'clickup.updateTask' ? 'clickup_task_id' : 'default'}
/>
<div>
<label>Path (optional)</label>
<label>{t('clickUpNodeConfig.pathOptional')}</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
@ -2119,7 +2132,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
disabled={searchTeamsLoading || !connectionId}
>
<option value="">
{searchTeamsLoading ? 'Workspaces werden geladen…' : 'Workspace wählen…'}
{searchTeamsLoading ? t('clickUpNodeConfig.workspacesWerdenGeladen') : t('clickUpNodeConfig.workspaceWaehlen')}
</option>
{searchTeams.map((t) => (
<option key={t.id} value={t.id}>
@ -2143,7 +2156,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
}}
disabled={!teamIdParam || searchListsLoading}
>
<option value="">{searchListsLoading ? 'Listen werden geladen…' : 'Liste wählen…'}</option>
<option value="">{searchListsLoading ? t('clickUpNodeConfig.listenWerdenGeladen') : t('clickUpNodeConfig.listeWaehlen')}</option>
{searchLists.map((L) => (
<option key={L.id} value={L.id}>
{L.name}
@ -2174,7 +2187,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
Erweitert: JSON (optional, wird mit den Zeilen zusammengeführt)
</summary>
<div style={{ marginTop: 8 }}>
<label>taskUpdate (JSON)</label>
<label>{t('clickUpNodeConfig.taskupdateJson')}</label>
<textarea
value={(params.taskUpdate as string) ?? ''}
onChange={(e) => updateParam('taskUpdate', e.target.value)}
@ -2188,7 +2201,7 @@ export const ClickUpNodeConfig: React.FC<NodeConfigRendererProps> = ({
{nodeType === 'clickup.uploadAttachment' && (
<div>
<label>File name (optional)</label>
<label>{t('clickUpNodeConfig.fileNameOptional')}</label>
<input
value={(params.fileName as string) ?? ''}
onChange={(e) => updateParam('fileName', e.target.value)}

View file

@ -5,25 +5,30 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder="Kommentar eingeben..."
/>
</div>
<div>
<label>
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Platzhalter</label>
<input
type="checkbox"
checked={(params.required as boolean) ?? true}
onChange={(e) => updateParam('required', e.target.checked)}
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder={t('commentNodeConfig.kommentarEingeben')}
/>
Pflichtfeld
</label>
</div>
</>
);
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.required as boolean) ?? true}
onChange={(e) => updateParam('required', e.target.checked)}
/>
Pflichtfeld
</label>
</div>
</>
);
};

View file

@ -5,29 +5,34 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder="Möchten Sie bestätigen?"
/>
</div>
<div>
<label>Bestätigen-Button</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
/>
</div>
<div>
<label>Ablehnen-Button</label>
<input
value={(params.rejectLabel as string) ?? 'Reject'}
onChange={(e) => updateParam('rejectLabel', e.target.value)}
/>
</div>
</>
);
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder={t('confirmationNodeConfig.moechtenSieBestaetigen')}
/>
</div>
<div>
<label>{t('confirmationNodeConfig.bestaetigenbutton')}</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
/>
</div>
<div>
<label>Ablehnen-Button</label>
<input
value={(params.rejectLabel as string) ?? 'Reject'}
onChange={(e) => updateParam('rejectLabel', e.target.value)}
/>
</div>
</>
);
};

View file

@ -6,13 +6,15 @@ import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/workflowApi';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'email.checkEmail',
}) => {
const { t } = useLanguage();
const [connections, setConnections] = useState<UserConnection[]>([]);
const [folders, setFolders] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
@ -57,7 +59,7 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
<option value="">{loading ? t('emailNodeConfig.loading') : t('emailNodeConfig.selectConnection')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalEmail ?? c.externalUsername ?? c.id}
@ -74,7 +76,7 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
disabled={foldersLoading || !connectionId}
>
<option value="">
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
{foldersLoading ? 'Loading folders...' : !connectionId ? t('emailNodeConfig.selectAccountFirst') : t('emailNodeConfig.selectFolder')}
</option>
{isSearch && <option value="All">All</option>}
{folders.length > 0
@ -92,9 +94,9 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
<>
<option value="Inbox">Inbox</option>
<option value="Drafts">Drafts</option>
<option value="SentItems">Sent Items</option>
<option value="DeletedItems">Deleted Items</option>
<option value="JunkEmail">Junk Email</option>
<option value="SentItems">{t('emailNodeConfig.sentItems')}</option>
<option value="DeletedItems">{t('emailNodeConfig.deletedItems')}</option>
<option value="JunkEmail">{t('emailNodeConfig.junkEmail')}</option>
</>
)}
{folderValue &&
@ -111,43 +113,43 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
{isSearch && (
<>
<div>
<label>Search query (optional)</label>
<label>{t('emailNodeConfig.searchQueryOptional')}</label>
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder="General search term (subject, body, from)"
placeholder={t('emailNodeConfig.generalSearchTermSubjectBody')}
/>
</div>
<div>
<label>From address (optional)</label>
<label>{t('emailNodeConfig.fromAddressOptional')}</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
placeholder={t('emailNodeConfig.egSenderexamplecom')}
/>
</div>
<div>
<label>To address (optional)</label>
<label>{t('emailNodeConfig.toAddressOptional')}</label>
<input
value={(params.toAddress as string) ?? ''}
onChange={(e) => updateParam('toAddress', e.target.value)}
placeholder="e.g. recipient@example.com"
placeholder={t('emailNodeConfig.egRecipientexamplecom')}
/>
</div>
<div>
<label>Subject contains (optional)</label>
<label>{t('emailNodeConfig.subjectContainsOptional')}</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
/>
</div>
<div>
<label>Body/content contains (optional)</label>
<label>{t('emailNodeConfig.bodycontentContainsOptional')}</label>
<input
value={(params.bodyContains as string) ?? ''}
onChange={(e) => updateParam('bodyContains', e.target.value)}
placeholder="Word or phrase in email body"
placeholder={t('emailNodeConfig.wordOrPhraseInEmail')}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
@ -157,7 +159,7 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
<label htmlFor="searchHasAttachment">{t('emailNodeConfig.onlyEmailsWithAttachment')}</label>
</div>
<div>
<label>Limit</label>
@ -172,19 +174,19 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
{nodeType === 'email.checkEmail' && (
<>
<div>
<label>From address (optional)</label>
<label>{t('emailNodeConfig.fromAddressOptional')}</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
placeholder={t('emailNodeConfig.egSenderexamplecom')}
/>
</div>
<div>
<label>Subject contains (optional)</label>
<label>{t('emailNodeConfig.subjectContainsOptional')}</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
@ -194,7 +196,7 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="hasAttachment">Only emails with attachment</label>
<label htmlFor="hasAttachment">{t('emailNodeConfig.onlyEmailsWithAttachment')}</label>
</div>
<div>
<label>Limit</label>
@ -213,7 +215,7 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
<input
value={(params.subject as string) ?? ''}
onChange={(e) => updateParam('subject', e.target.value)}
placeholder="Email subject (or leave empty if connected to AI node above)"
placeholder={t('emailNodeConfig.emailSubjectOrLeaveEmpty')}
/>
</div>
<div>
@ -221,16 +223,16 @@ export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
<textarea
value={(params.body as string) ?? ''}
onChange={(e) => updateParam('body', e.target.value)}
placeholder="Email body (or leave empty if connected to AI node above)"
placeholder={t('emailNodeConfig.emailBodyOrLeaveEmpty')}
rows={4}
/>
</div>
<div>
<label>To (optional)</label>
<label>{t('emailNodeConfig.toOptional')}</label>
<input
value={(params.to as string) ?? ''}
onChange={(e) => updateParam('to', e.target.value)}
placeholder="Recipient(s) (or from AI when connected)"
placeholder={t('emailNodeConfig.recipientsOrFromAiWhen')}
/>
</div>
</>

View file

@ -9,6 +9,8 @@ import { RefSourceSelect } from '../shared/RefSourceSelect';
import { isRef, type DataRef } from '../shared/dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const OUTPUT_FORMATS = ['docx', 'pdf', 'txt', 'md', 'html', 'xlsx', 'csv', 'json'];
const TEMPLATE_OPTIONS = ['default', 'corporate', 'minimal'];
const LANGUAGES = ['de', 'en', 'fr', 'it', 'es'];
@ -22,6 +24,7 @@ function normalizeContentSources(v: unknown): (DataRef | null)[] {
}
export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const contentSources = normalizeContentSources(params.contentSources ?? params.contentSource ?? []);
const setContentSources = (next: (DataRef | null)[]) => {
@ -41,20 +44,20 @@ export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params
return (
<>
<div className={styles.fileCreateContentSources}>
<label>Inhalte (welche Kontexte nacheinander in die Datei?)</label>
<label>{t('fileCreateNodeConfig.inhalteWelcheKontexteNacheinanderIn')}</label>
{contentSources.map((ref, i) => (
<div key={i} className={styles.contentSourceRow}>
<RefSourceSelect
value={ref}
onChange={(r) => setItem(i, r)}
placeholder="Quelle wählen…"
placeholder={t('fileCreateNodeConfig.quelleWaehlen')}
/>
<button
type="button"
className={styles.contentSourceRemoveBtn}
onClick={() => removeItem(i)}
title="Entfernen"
aria-label="Inhalt entfernen"
title={t('fileCreateNodeConfig.entfernen')}
aria-label={t('fileCreateNodeConfig.inhaltEntfernen')}
>
×
</button>
@ -91,7 +94,7 @@ export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params
/>
</div>
<div>
<label>Vorlage / Stil</label>
<label>{t('fileCreateNodeConfig.vorlageStil')}</label>
<select
value={(params.templateName as string) ?? 'default'}
onChange={(e) => updateParam('templateName', e.target.value)}
@ -104,7 +107,7 @@ export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params
</select>
</div>
<div>
<label>Sprache</label>
<label>{t('fileCreateNodeConfig.sprache')}</label>
<select
value={(params.language as string) ?? 'de'}
onChange={(e) => updateParam('language', e.target.value)}

View file

@ -10,6 +10,8 @@ import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/workflowApi';
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const browseDetailsStyle: React.CSSProperties = {
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
@ -58,13 +60,13 @@ function isFolderPickerNode(nodeType: string): boolean {
return nodeType === 'sharepoint.uploadFile' || nodeType === 'sharepoint.listFiles';
}
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const { t } = useLanguage();
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseExpanded, setBrowseExpanded] = useState(false);
const [findFileBrowseExpanded, setFindFileBrowseExpanded] = useState(false);
@ -153,7 +155,7 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
<option value="">{connectionsLoading ? t('sharePointNodeConfig.loading') : t('sharePointNodeConfig.selectConnection')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
@ -164,7 +166,7 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
{needsSearch && (
<div>
<label>Search query / path</label>
<label>{t('sharePointNodeConfig.searchQueryPath')}</label>
<input
value={(params.searchQuery as string) ?? ''}
onChange={(e) => updateParam('searchQuery', e.target.value)}
@ -175,7 +177,7 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
{showPathFieldsForList && (
<div>
<label>Folder path</label>
<label>{t('sharePointNodeConfig.folderPath')}</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
@ -190,8 +192,8 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
{nodeType === 'sharepoint.uploadFile'
? 'Target folder path'
: nodeType === 'sharepoint.downloadFile'
? 'File path'
: 'Path'}
? t('sharePointNodeConfig.filePath')
: t('sharePointNodeConfig.path')}
</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
@ -209,11 +211,11 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
{needsSiteId && (
<div>
<label>Site ID</label>
<label>{t('sharePointNodeConfig.siteId')}</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder="SharePoint site ID"
placeholder={t('sharePointNodeConfig.sharepointSiteId')}
/>
</div>
)}
@ -221,7 +223,7 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source file</label>
<label>{t('sharePointNodeConfig.sourceFile')}</label>
<input
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
@ -229,7 +231,7 @@ export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
/>
</div>
<div>
<label>Destination folder</label>
<label>{t('sharePointNodeConfig.destinationFolder')}</label>
<input
value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destPath', e.target.value)}

View file

@ -7,13 +7,15 @@ import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'trustee.extractFromFiles',
}) => {
const { t } = useLanguage();
const [connections, setConnections] = useState<UserConnection[]>([]);
const [loading, setLoading] = useState(false);
@ -32,24 +34,24 @@ export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
return (
<>
<div>
<label>Trustee Instance ID</label>
<label>{t('trusteeNodeConfig.trusteeInstanceId')}</label>
<input
value={(params.featureInstanceId as string) ?? ''}
onChange={(e) => updateParam('featureInstanceId', e.target.value)}
placeholder="Trustee Feature-Instanz-ID"
placeholder={t('trusteeNodeConfig.trusteeFeatureinstanzid')}
/>
</div>
{isExtract && (
<>
<div>
<label>SharePoint Connection (optional)</label>
<label>{t('trusteeNodeConfig.sharepointConnectionOptional')}</label>
<select
value={(params.connectionId as string) ?? ''}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? 'Laden...' : 'Keine (Dateien aus vorherigem Schritt)'}</option>
<option value="">{loading ? t('trusteeNodeConfig.laden') : t('trusteeNodeConfig.keineDateienAusVorherigemSchritt')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
@ -58,7 +60,7 @@ export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
</select>
</div>
<div>
<label>SharePoint Ordnerpfad (optional)</label>
<label>{t('trusteeNodeConfig.sharepointOrdnerpfadOptional')}</label>
<input
value={(params.sharepointFolder as string) ?? ''}
onChange={(e) => updateParam('sharepointFolder', e.target.value)}
@ -66,11 +68,11 @@ export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
/>
</div>
<div>
<label>AI Prompt (optional)</label>
<label>{t('trusteeNodeConfig.aiPromptOptional')}</label>
<textarea
value={(params.prompt as string) ?? ''}
onChange={(e) => updateParam('prompt', e.target.value)}
placeholder="Zusätzliche Anweisungen für die AI-Extraktion"
placeholder={t('trusteeNodeConfig.zusaetzlicheAnweisungenFuerDieAiextraktion')}
rows={3}
/>
</div>
@ -79,11 +81,11 @@ export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
{!isExtract && (
<div>
<label>Document List (Referenz)</label>
<label>{t('trusteeNodeConfig.documentListReferenz')}</label>
<input
value={(params.documentList as string) ?? ''}
onChange={(e) => updateParam('documentList', e.target.value)}
placeholder="Referenz auf vorherigen Schritt (automatisch verknüpft)"
placeholder={t('trusteeNodeConfig.referenzAufVorherigenSchrittAutomatisch')}
/>
</div>
)}

View file

@ -8,6 +8,8 @@ import type { NodeConfigRendererProps } from './types';
import { getAcceptValues, parseAllowedTypes } from '../runtime/fileTypeMimeMapping';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function buildAcceptString(allowedTypes: string[]): string {
if (allowedTypes.length === 0) return '';
return allowedTypes.join(',');
@ -22,6 +24,7 @@ export function getAcceptStringFromConfig(config: Record<string, unknown>): stri
const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const allowedTypes = parseAllowedTypes(params);
const maxSize = (params.maxSize as number) ?? 10;
const multiple = (params.multiple as boolean) ?? false;
@ -37,7 +40,7 @@ export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
return (
<div className={styles.uploadNodeConfig}>
<div className={styles.configBlock}>
<label>Erlaubte Dateitypen</label>
<label>{t('uploadNodeConfig.erlaubteDateitypen')}</label>
<p className={styles.configHint}>
Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
</p>
@ -55,7 +58,7 @@ export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
</div>
</div>
<div className={styles.configBlock}>
<label>Max. Größe (MB)</label>
<label>{t('uploadNodeConfig.maxGroesseMb')}</label>
<input
type="number"
min={0.1}

View file

@ -8,12 +8,14 @@ import type { FormField, NodeConfigRendererProps } from '../configs/types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
}) => {
const { t } = useLanguage();
const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
@ -74,7 +76,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
<div className={styles.formFieldRowHeader}>
<span
className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen"
title={t('formNodeConfig.zumVerschiebenZiehen')}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i));
@ -131,8 +133,8 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
<option value="clickup_tasks">ClickUp-Aufgabe (Referenz)</option>
<option value="clickup_status">ClickUp-Status (Liste)</option>
<option value="clickup_tasks">{t('formNodeConfig.clickupaufgabeReferenz')}</option>
<option value="clickup_status">{t('formNodeConfig.clickupstatusListe')}</option>
</select>
<label className={styles.formFieldRequiredLabel}>
<input
@ -149,7 +151,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
<button
type="button"
onClick={() => removeField(i)}
title="Feld entfernen"
title={t('formNodeConfig.feldEntfernen')}
className={styles.formFieldRemoveButton}
>
<FaTimes />
@ -196,7 +198,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
Listen-ID (verknüpfte Liste / Ziel-Liste)
</label>
<input
placeholder="z. B. aus ClickUp-URL …/list/123456789"
placeholder={t('formNodeConfig.zBAusClickupurlList123456789')}
value={f.clickupListId ?? ''}
onChange={(e) => {
const next = [...fields];

View file

@ -25,6 +25,8 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
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>
@ -149,10 +151,11 @@ const JsonEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) =>
const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
React.useEffect(() => {
if (!instanceId || !request) return;
request(`/api/graphicalEditor/${instanceId}/options/user.connection`)
request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' })
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@ -167,7 +170,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value=""> Select connection </option>
<option value="">{t('index.selectConnection')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
@ -196,6 +199,7 @@ const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, al
};
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
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));
@ -211,21 +215,22 @@ const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }
<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="neq">{t('index.notEquals')}</option>
<option value="contains">contains</option>
<option value="gt">greater than</option>
<option value="lt">less than</option>
<option value="gt">{t('index.greaterThan')}</option>
<option value="lt">{t('index.lessThan')}</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>
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('index.addCase')}</button>
</div>
);
};
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
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));
@ -255,12 +260,13 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<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>
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('index.addField')}</button>
</div>
);
};
const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const rows = Array.isArray(value) ? value : [];
const addRow = () => onChange([...rows, { key: '', value: '' }]);
const removeRow = (idx: number) => onChange(rows.filter((_: unknown, i: number) => i !== idx));
@ -279,26 +285,30 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<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>
<button onClick={addRow} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('index.addRow')}</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 CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
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)}
placeholder={t('index.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' }}>{t('index.cronMinHourDayMonth')}</p>
</div>
);
};
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
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 (
@ -307,14 +317,14 @@ const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange
<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="neq">{t('index.notEquals')}</option>
<option value="gt">{t('index.greaterThan')}</option>
<option value="lt">{t('index.lessThan')}</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>
<option value="empty">{t('index.isEmpty')}</option>
<option value="not_empty">{t('index.isNotEmpty')}</option>
<option value="is_true">{t('index.isTrue')}</option>
<option value="is_false">{t('index.isFalse')}</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>
@ -323,6 +333,7 @@ const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange
};
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const mappings = Array.isArray(value) ? value : [];
const addMapping = () => onChange([...mappings, { sourceField: '', outputField: '' }]);
const removeMapping = (idx: number) => onChange(mappings.filter((_: unknown, i: number) => i !== idx));
@ -336,18 +347,19 @@ const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<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' }} />
<input type="text" placeholder={t('index.sourceField')} 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' }} />
<input type="text" placeholder={t('index.outputField')} 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>
<button onClick={addMapping} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('index.addMapping')}</button>
</div>
);
};
const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, [field]: val });
return (
@ -357,13 +369,13 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
<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="neq">{t('index.notEquals')}</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>
<option value="startsWith">{t('index.startsWith')}</option>
<option value="isEmpty">{t('index.isEmpty')}</option>
<option value="isNotEmpty">{t('index.isNotEmpty')}</option>
<option value="gt">{t('index.greaterThan')}</option>
<option value="lt">{t('index.lessThan')}</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>

View file

@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
type: 'condition';
ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
@ -28,6 +30,7 @@ function parseCondition(v: unknown): StructuredCondition | null {
}
export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const cond = parseCondition(params.condition);
@ -97,7 +100,7 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>Datenquelle</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder="Formular-Feld wählen…" />
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('ifElseNodeConfig.formularfeldWaehlen')} />
</div>
<div className={styles.ifElseConditionRow}>
<label>Vergleich</label>
@ -117,7 +120,7 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
>
<option value=""> MIME-Type wählen </option>
<option value="">{t('ifElseNodeConfig.mimetypeWaehlen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
@ -139,8 +142,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
: fieldType === 'date'
? 'TT.MM.JJJJ'
: isMimeTypeRef
? 'z.B. application/pdf'
: 'z.B. CH'
? t('ifElseNodeConfig.zbApplicationpdf')
: t('ifElseNodeConfig.zbCh')
}
/>
)}

View file

@ -11,6 +11,8 @@ import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
interface DataPickerProps {
open: boolean;
onClose: () => void;
@ -95,8 +97,7 @@ function _resolveSchemaForNode(
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
}
export const DataPicker: React.FC<DataPickerProps> = ({
open,
export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClose,
onPick,
availableSourceIds,
@ -104,6 +105,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({
nodeOutputsPreview,
getNodeLabel,
}) => {
const { t } = useLanguage();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showSystem, setShowSystem] = useState(false);
const ctx = useAutomation2DataFlow();
@ -113,7 +115,12 @@ export const DataPicker: React.FC<DataPickerProps> = ({
const catalog = ctx?.portTypeCatalog ?? {};
const systemVars = ctx?.systemVariables ?? {};
const nodeTypes = ctx?.nodeTypes ?? [];
const connections = ctx?.connections ?? [];
const connectionsRaw = ctx?.connections ?? [];
const connections = connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
}));
const toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
@ -138,8 +145,8 @@ export const DataPicker: React.FC<DataPickerProps> = ({
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>Datenquelle wählen</h4>
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label="Schließen">
<h4 className={styles.dataPickerTitle}>{t('dataPicker.datenquelleWaehlen')}</h4>
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('dataPicker.schliessen')}>
×
</button>
</div>
@ -180,7 +187,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({
return node?.type !== 'trigger.manual';
});
if (filteredIds.length === 0 && Object.keys(systemVars).length === 0) {
return <p className={styles.dataPickerEmpty}>Keine vorherigen Nodes verfügbar.</p>;
return <p className={styles.dataPickerEmpty}>{t('dataPicker.keineVorherigenNodesVerfuegbar')}</p>;
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);

View file

@ -8,12 +8,15 @@ import {
createValue,
formatRefLabel,
type DataRef,
type SystemVarRef,
} from './dataRef';
import { RefSourceSelect } from './RefSourceSelect';
import { DataPicker } from './DataPicker';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export type FieldType = 'textarea' | 'input';
interface DynamicValueFieldProps {
@ -28,14 +31,14 @@ interface DynamicValueFieldProps {
variant?: 'picker' | 'dropdown';
}
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
paramKey,
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({ paramKey,
value,
onChange,
label,
placeholder,
variant = 'picker',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = useState(false);
@ -47,7 +50,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
});
const canUseRef = dataFlow !== null && hasUsefulSources;
const handleSetRef = (newRef: DataRef | null) => {
const handleSetRef = (newRef: DataRef | SystemVarRef | null) => {
onChange(paramKey, newRef ?? createValue(''));
};
@ -55,7 +58,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
return (
<div className={styles.dynamicValueField}>
<label>{label}</label>
<p className={styles.dynamicValueEmptyHint}>Keine vorherigen Nodes verfügbar.</p>
<p className={styles.dynamicValueEmptyHint}>{t('dynamicValueField.keineVorherigenNodesVerfuegbar')}</p>
</div>
);
}

View file

@ -13,6 +13,8 @@ import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext
import { isRef, isValue, createValue } from './dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function parseHybrid(value: unknown): { staticStr: string } {
if (isRef(value)) return { staticStr: '' };
if (isValue(value)) {
@ -37,8 +39,7 @@ export interface HybridStaticRefFieldProps {
pathPickMode?: PathPickMode;
}
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
label,
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({ label,
value,
onChange,
multiline,
@ -46,6 +47,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
placeholder,
pathPickMode = 'default',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const hasSources =
dataFlow &&
@ -83,7 +85,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
<StatischKontextSelect
value={value}
onChange={onChange}
placeholder="— Quelle wählen —"
placeholder={t('hybridStaticRefField.quelleWaehlen')}
pathPickMode={pathPickMode}
/>
</div>

View file

@ -9,6 +9,8 @@ import { refToOptionValue, optionValueToRef } from './RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
interface LoopOption {
ref: DataRef;
label: string;
@ -153,11 +155,11 @@ interface LoopItemsSelectProps {
placeholder?: string;
}
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
value,
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
onChange,
placeholder = 'Über was soll iteriert werden?',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
@ -182,7 +184,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
return (
<div className={styles.ifElseConditionRow}>
<label>Datenquelle für Iteration</label>
<label>{t('loopItemsSelect.datenquelleFuerIteration')}</label>
<select
value={currentValue}
onChange={(e) => {

View file

@ -6,6 +6,8 @@ import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../configs/types';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
type FormField = {
name: string;
label: string;
@ -15,17 +17,17 @@ type FormField = {
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function parseFields(params: Record<string, unknown>): FormField[] {
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: 'Feld 1', type: 'text' }];
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('formStartNodeConfig.field1'), type: 'text' }];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
const t = String(o.type ?? 'text');
const fieldType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `Feld ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(t as (typeof FORM_FIELD_TYPES)[number]) ? t : 'text'
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return {
@ -42,7 +44,8 @@ function parseFields(params: Record<string, unknown>): FormField[] {
}
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const fields = useMemo(() => parseFields(params), [params]);
const { t } = useLanguage();
const fields = useMemo(() => _parseFields(params, t), [params, t]);
const setFields = (next: FormField[]) => {
updateParam('formFields', next);
@ -59,7 +62,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<div key={idx} className={styles.formFieldRow}>
<input
className={styles.startsInput}
placeholder="Name (Payload-Key)"
placeholder={t('formStartNodeConfig.namePayloadkey')}
value={f.name}
onChange={(e) => {
const next = [...fields];
@ -82,11 +85,11 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type}
onChange={(e) => {
const next = [...fields];
const t = e.target.value as FormField['type'];
if (t === 'clickup_status') {
const fieldType = e.target.value as FormField['type'];
if (fieldType === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else {
next[idx] = { name: f.name, label: f.label, type: t };
next[idx] = { name: f.name, label: f.label, type: fieldType };
}
setFields(next);
}}
@ -96,7 +99,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<option value="email">E-Mail</option>
<option value="date">Datum</option>
<option value="boolean">Ja/Nein</option>
<option value="clickup_status">ClickUp-Status (Liste)</option>
<option value="clickup_status">{t('formStartNodeConfig.clickupstatusListe')}</option>
</select>
<button
type="button"
@ -111,7 +114,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
type="button"
className={styles.startsAddBtn}
onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: 'Neues Feld', type: 'text' }])
setFields([...fields, { name: `field${fields.length + 1}`, label: t('formStartNodeConfig.newField'), type: 'text' }])
}
>
+ Feld

View file

@ -16,13 +16,17 @@ import {
} from '../runtime/scheduleCron';
import styles from '../../editor/Automation2FlowEditor.module.css';
const MODE_OPTIONS: { value: ScheduleMode; title: string; subtitle: string }[] = [
{ value: 'daily', title: 'Täglich', subtitle: 'Jeden Tag zur gleichen Zeit' },
{ value: 'weekdays', title: 'Werktage', subtitle: 'Montag bis Freitag' },
{ value: 'weekly', title: 'Bestimmte Tage', subtitle: 'Wochentage auswählen' },
{ value: 'calendar', title: 'Ein anderer Zeitraum', subtitle: 'Monatlich oder jährlich wiederkehrend' },
{ value: 'interval', title: 'Intervall', subtitle: 'In regelmäßigen Abständen' },
];
import { useLanguage } from '../../../../providers/language/LanguageContext';
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
return [
{ value: 'daily', title: t('scheduleStartNodeConfig.taeglich'), subtitle: t('scheduleStartNodeConfig.jedenTagZurGleichenZeit') },
{ value: 'weekdays', title: t('scheduleStartNodeConfig.werktage'), subtitle: t('scheduleStartNodeConfig.montagBisFreitag') },
{ value: 'weekly', title: t('scheduleStartNodeConfig.bestimmteTage'), subtitle: t('scheduleStartNodeConfig.wochentageAuswaehlen') },
{ value: 'calendar', title: t('scheduleStartNodeConfig.einAndererZeitraum'), subtitle: t('scheduleStartNodeConfig.monatlichOderJaehrlich') },
{ value: 'interval', title: t('scheduleStartNodeConfig.intervall'), subtitle: t('scheduleStartNodeConfig.inRegelmaessigenAbstaenden') },
];
}
const MONTH_NAMES_DE = [
'Januar',
@ -39,13 +43,15 @@ const MONTH_NAMES_DE = [
'Dezember',
];
const INTERVAL_UNITS: { value: IntervalUnit; label: string; title: string }[] = [
{ value: 'seconds', label: 'sek', title: 'Sekunden' },
{ value: 'minutes', label: 'min', title: 'Minuten' },
{ value: 'hours', label: 'h', title: 'Stunden' },
{ value: 'days', label: 'd', title: 'Tage' },
{ value: 'years', label: 'a', title: 'Jahre' },
];
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
return [
{ value: 'seconds', label: t('scheduleStartNodeConfig.sek'), title: t('scheduleStartNodeConfig.sekunden') },
{ value: 'minutes', label: t('scheduleStartNodeConfig.min'), title: t('scheduleStartNodeConfig.minuten') },
{ value: 'hours', label: t('scheduleStartNodeConfig.h'), title: t('scheduleStartNodeConfig.stunden') },
{ value: 'days', label: t('scheduleStartNodeConfig.d'), title: t('scheduleStartNodeConfig.tage') },
{ value: 'years', label: t('scheduleStartNodeConfig.a'), title: t('scheduleStartNodeConfig.jahre') },
];
}
function timeString(hour: number, minute: number): string {
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
@ -77,6 +83,9 @@ function clampInterval(value: number, unit: IntervalUnit): number {
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const modeOptions = _getModeOptions(t);
const intervalUnits = _getIntervalUnits(t);
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
const prefersReducedMotion = useReducedMotion();
const specModeRef = useRef(spec.mode);
@ -128,7 +137,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
const onModeCardPointerEvent = (
phase: 'pointerdown' | 'click',
e: React.PointerEvent | React.MouseEvent,
o: (typeof MODE_OPTIONS)[number]
o: { value: ScheduleMode; title: string; subtitle: string }
) => {
const el = e.target as HTMLElement;
const cur = e.currentTarget as HTMLElement;
@ -206,7 +215,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
<LayoutGroup>
<div className={styles.scheduleModeStack}>
{MODE_OPTIONS.map((o) => (
{modeOptions.map((o) => (
<motion.div
key={o.value}
data-schedule-mode={o.value}
@ -394,7 +403,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'interval' && (
<div className={styles.scheduleIntervalRow}>
<span className={styles.scheduleFieldLabel}>Alle</span>
<span className={styles.scheduleFieldLabel}>{t('scheduleStartNodeConfig.alle')}</span>
<input
type="number"
min={1}
@ -411,9 +420,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
className={styles.scheduleUnitSelect}
value={spec.intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
title={INTERVAL_UNITS.find((u) => u.value === spec.intervalUnit)?.title}
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
>
{INTERVAL_UNITS.map((u) => (
{intervalUnits.map((u) => (
<option key={u.value} value={u.value} title={u.title}>
{u.label}
</option>

View file

@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
@ -31,6 +33,7 @@ function normalizeCase(c: unknown): SwitchCase {
}
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const valueParam = params.value;
@ -111,7 +114,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
onChange={(e) => handleCaseValueChange(index, e.target.value)}
className={styles.startsInput}
>
<option value=""> MIME-Type wählen </option>
<option value="">{t('switchNodeConfig.mimetypeWaehlen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
@ -151,9 +154,9 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
}}
className={styles.startsInput}
>
<option value=""> wählen </option>
<option value="true">Ja / wahr</option>
<option value="false">Nein / falsch</option>
<option value="">{t('switchNodeConfig.waehlen')}</option>
<option value="true">{t('switchNodeConfig.jaWahr')}</option>
<option value="false">{t('switchNodeConfig.neinFalsch')}</option>
</select>
);
}
@ -186,24 +189,24 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
<RefSourceSelect
value={ref}
onChange={handleRefChange}
placeholder="Feld zum Vergleichen wählen…"
placeholder={t('switchNodeConfig.feldZumVergleichenWaehlen')}
/>
</div>
{!ref && (
<div className={styles.ifElseConditionRow}>
<label>Fester Wert (falls keine Referenz)</label>
<label>{t('switchNodeConfig.festerWertFallsKeineReferenz')}</label>
<input
type="text"
value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder="z.B. CH oder 42"
placeholder={t('switchNodeConfig.zbChOder42')}
/>
</div>
)}
<div className={styles.ifElseConditionRow}>
<label>Fälle (Reihenfolge = Ausgang)</label>
<label>{t('switchNodeConfig.faelleReihenfolgeAusgang')}</label>
<div className={styles.formFieldsList}>
{cases.map((c, i) => {
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];

View file

@ -16,6 +16,8 @@ import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/* ── Public types ──────────────────────────────────────────────────────── */
export interface FolderNode {
@ -184,6 +186,7 @@ interface SelectionCtx {
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const { t } = useLanguage();
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
@ -267,7 +270,7 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('folderTree.dateienLoeschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
@ -275,7 +278,7 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('folderTree.loeschen')}>
<FaTrash />
</button>
)
@ -308,7 +311,7 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
e.stopPropagation();
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
}}
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
title={file.neutralize ? t('folderTree.neutralisierungAktivKlickenZumDeaktivieren') : t('folderTree.neutralisierungAusKlickenZumAktivieren')}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
@ -351,6 +354,7 @@ function _TreeNode({
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder,
}: TreeNodeProps) {
const { t } = useLanguage();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false);
@ -377,7 +381,7 @@ function _TreeNode({
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
const name = await promptFolderName('Neuer Ordnername:', { title: t('folderTree.newFolder'), placeholder: 'Ordnername' });
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
@ -496,37 +500,37 @@ function _TreeNode({
)}
<span className={styles.actions}>
{onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title="Ordner herunterladen (ZIP)">
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('folderTree.ordnerHerunterladenZip')}>
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title="Neuer Unterordner">
<button className={styles.actionBtn} onClick={_handleAdd} title={t('folderTree.neuerUnterordner')}>
<FaPlus />
</button>
)}
{onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title="Umbenennen">
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('folderTree.umbenennen')}>
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('folderTree.ordnerLoeschen')}`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('folderTree.dateienLoeschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('folderTree.loeschen')}>
<FaTrash />
</button>
)}
@ -576,6 +580,7 @@ export default function FolderTree({
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) {
const { t } = useLanguage();
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
@ -751,7 +756,7 @@ export default function FolderTree({
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
<span className={styles.rootActions}>
{onRefresh && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title="Aktualisieren">
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title={t('folderTree.aktualisieren')}>
<FaSyncAlt />
</button>
)}
@ -760,10 +765,10 @@ export default function FolderTree({
className={styles.actionBtn}
onClick={async (e) => {
e.stopPropagation();
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
const name = await promptFolderName('Neuer Ordnername:', { title: t('folderTree.newFolder'), placeholder: 'Ordnername' });
if (name?.trim()) await onCreateFolder(name.trim(), null);
}}
title="Neuer Ordner"
title={t('folderTree.neuerOrdner')}
>
<FaPlus />
</button>

View file

@ -455,6 +455,8 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Validate all fields
const validateFields = (): boolean => {
const { t } = useLanguage();
const newErrors: Record<string, string> = {};
const filteredAttrs = getFilteredAttributes();
@ -667,10 +669,10 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' };
const languages = [
{ code: 'en', label: 'English', required: true },
{ code: 'ge', label: 'German', required: false },
{ code: 'fr', label: 'French', required: false },
{ code: 'it', label: 'Italian', required: false }
{ code: 'en', label: 'EN', required: true },
{ code: 'ge', label: 'DE', required: false },
{ code: 'fr', label: 'FR', required: false },
{ code: 'it', label: 'IT', required: false }
];
const handleMultilingualChange = (langCode: string, langValue: string) => {

View file

@ -5,6 +5,8 @@ import {
PieChart, Pie, Cell, Legend
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import type {
FormGeneratorReportProps,
ReportSection,
@ -114,8 +116,10 @@ const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => {
// --- Bar Chart (vertical) ---
const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const chartData = section.data.map(d => ({
@ -158,8 +162,10 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
// --- Horizontal Bar Chart ---
const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const maxValue = Math.max(...section.data.map(d => d.value), 0.01);
@ -189,8 +195,10 @@ const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode:
// --- Line Chart ---
const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
@ -235,8 +243,10 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
// --- Area Chart ---
const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
@ -281,8 +291,10 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
// --- Pie Chart ---
const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
if (!section.data?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const formatter = section.formatValue || ((v: number) => _defaultFormatCurrency(v, currencyCode));
@ -339,10 +351,11 @@ interface ReportTableSectionProps {
}
const _ReportTableSection: React.FC<ReportTableSectionProps> = ({ section, currencyCode }) => {
const { t } = useLanguage();
const [showAll, setShowAll] = useState(false);
if (!section.rows?.length) {
return <div className={styles.noData}>Keine Daten</div>;
return <div className={styles.noData}>{t('formGeneratorReport.keineDaten')}</div>;
}
const maxRows = section.maxRows || 0;
@ -440,6 +453,8 @@ const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode
}
const _renderContent = (): React.ReactNode => {
const { t } = useLanguage();
switch (section.type) {
case 'barChart':
return _renderBarChart(section, currencyCode);
@ -454,7 +469,7 @@ const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode
case 'table':
return <_ReportTableSection section={section} currencyCode={currencyCode} />;
default:
return <div className={styles.noData}>Unbekannter Sektionstyp</div>;
return <div className={styles.noData}>{t('formGeneratorReport.unbekannterSektionstyp')}</div>;
}
};
@ -482,6 +497,7 @@ interface ToolbarProps {
const _Toolbar: React.FC<ToolbarProps> = ({
periodSelector, dateRangeSelector, filters, filterState, onFilterStateChange
}) => {
const { t } = useLanguage();
const hasPeriod = !!periodSelector;
const hasDateRange = dateRangeSelector?.enabled;
const hasFilters = filters && filters.length > 0;
@ -518,22 +534,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
const monthOptions = [
{ value: 1, label: 'Januar' }, { value: 2, label: 'Februar' },
{ value: 3, label: 'März' }, { value: 4, label: 'April' },
{ value: 5, label: 'Mai' }, { value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' }, { value: 8, label: 'August' },
{ value: 9, label: 'September' }, { value: 10, label: 'Oktober' },
{ value: 11, label: 'November' }, { value: 12, label: 'Dezember' }
];
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: String(i + 1),
}));
const _renderPeriodLabel = (p: ReportPeriod): string => {
const labels: Record<ReportPeriod, string> = {
day: 'Tagesansicht',
week: 'Wochenansicht',
month: 'Monatsansicht',
quarter: 'Quartalsansicht',
year: 'Jahresansicht'
day: t('formGeneratorReport.tagesansicht'),
week: t('formGeneratorReport.wochenansicht'),
month: t('formGeneratorReport.monatsansicht'),
quarter: t('formGeneratorReport.quartalsansicht'),
year: t('formGeneratorReport.jahresansicht')
};
return labels[p] || p;
};
@ -543,7 +555,7 @@ const _Toolbar: React.FC<ToolbarProps> = ({
{/* Period Selector */}
{hasPeriod && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>Zeitraum</span>
<span className={styles.toolbarLabel}>{t('formGeneratorReport.zeitraum')}</span>
<select
className={styles.select}
value={filterState.period || periodSelector!.defaultPeriod}
@ -588,7 +600,7 @@ const _Toolbar: React.FC<ToolbarProps> = ({
{/* Date Range */}
{hasDateRange && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>Von</span>
<span className={styles.toolbarLabel}>{t('formGeneratorReport.von')}</span>
<input
type="date"
className={styles.dateInput}
@ -649,7 +661,7 @@ export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
subtitle,
sections,
loading = false,
noDataMessage = 'Keine Daten verfügbar',
noDataMessage,
periodSelector,
dateRangeSelector,
filters,
@ -657,6 +669,7 @@ export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
currencyCode = 'CHF',
className
}) => {
const { t } = useLanguage();
// Build initial filter state
const initialFilterState = useMemo((): ReportFilterState => {
const state: ReportFilterState = { filters: {} };
@ -715,7 +728,7 @@ export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
filterState={filterState}
onFilterStateChange={_handleFilterStateChange}
/>
<div className={styles.loadingContainer}>Lade Daten...</div>
<div className={styles.loadingContainer}>{t('formGeneratorReport.ladeDaten')}</div>
</div>
);
}
@ -737,7 +750,7 @@ export const FormGeneratorReport: React.FC<FormGeneratorReportProps> = ({
filterState={filterState}
onFilterStateChange={_handleFilterStateChange}
/>
<div className={styles.noData}>{noDataMessage}</div>
<div className={styles.noData}>{noDataMessage || t('formGeneratorReport.keineDatenVerfuegbar')}</div>
</div>
);
}

View file

@ -11,7 +11,11 @@ import { NotificationBell } from '../NotificationBell';
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
import styles from './UserSection.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const UserSection: React.FC = () => {
const { t } = useLanguage();
const { user, logout } = useCurrentUser();
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
@ -139,7 +143,7 @@ export const UserSection: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowLegalModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2>Rechtliche Hinweise</h2>
<h2>{t('userSection.rechtlicheHinweise')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowLegalModal(false)}
@ -149,25 +153,25 @@ export const UserSection: React.FC = () => {
</div>
<div className={styles.modalContent}>
<div className={styles.legalSection}>
<h3>Datenverarbeitung und KI-Nutzung</h3>
<h3>{t('userSection.datenverarbeitungUndKinutzung')}</h3>
<h4>1. Einwilligung zur Datenverarbeitung</h4>
<p>Mit der Nutzung dieser Anwendung stimmen Sie zu und erklären sich mit den folgenden Bedingungen zur Verarbeitung Ihrer Daten durch künstliche Intelligenz einverstanden:</p>
<h4>{t('userSection.1EinwilligungZurDatenverarbeitung')}</h4>
<p>{t('userSection.mitDerNutzungDieserAnwendung')}</p>
<ul>
<li>Sie autorisieren die Erfassung, Verarbeitung, Übertragung und Speicherung aller Daten, die Sie bei der Nutzung unserer Dienste bereitstellen.</li>
<li>Nutzerdaten können an Drittanbieter von künstlicher Intelligenz übertragen werden (z.B. OpenAI).</li>
<li>Diese Einwilligung erstreckt sich auf alle Inhalte, einschließlich Text, Bilder, Dokumente und Gesprächsverläufe.</li>
<li>{t('userSection.sieAutorisierenDieErfassungVerarbeitung')}</li>
<li>{t('userSection.nutzerdatenKoennenAnDrittanbieterVon')}</li>
<li>{t('userSection.dieseEinwilligungErstrecktSichAuf')}</li>
</ul>
<h4>2. Anerkennung der KI-Verarbeitungsrisiken</h4>
<h4>{t('userSection.2AnerkennungDerKiverarbeitungsrisiken')}</h4>
<ul>
<li>KI-Systeme können unerwartete oder ungenaue Ausgaben erzeugen.</li>
<li>KI-Dienste können Daten gemäß ihren eigenen Nutzungsbedingungen speichern oder daraus lernen.</li>
<li>Trotz Sicherheitsmaßnahmen können Daten anfällig für unbefugten Zugriff sein.</li>
<li>{t('userSection.kisystemeKoennenUnerwarteteOderUngenaue')}</li>
<li>{t('userSection.kidiensteKoennenDatenGemaessIhren')}</li>
<li>{t('userSection.trotzSicherheitsmassnahmenKoennenDatenAnfaellig')}</li>
</ul>
<h4>3. Haftungsausschluss</h4>
<p>Im größtmöglichen Umfang verzichten Sie auf Ansprüche, die sich aus der KI-Verarbeitung ergeben, einschließlich Datenverletzungen und unbeabsichtigter Offenlegung.</p>
<h4>{t('userSection.3Haftungsausschluss')}</h4>
<p>{t('userSection.imGroesstmoeglichenUmfangVerzichtenSie')}</p>
</div>
<div className={styles.legalLinks}>

View file

@ -10,6 +10,8 @@ import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaC
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
import styles from './NotificationBell.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// Icon mapping for notification types
const typeIcons: Record<string, React.ReactNode> = {
invitation: <FaEnvelope />,
@ -43,6 +45,7 @@ interface NotificationBellProps {
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const { t } = useLanguage();
const {
notifications,
unreadCount,
@ -156,7 +159,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
<div className={styles.dropdown}>
{/* Header */}
<div className={styles.header}>
<h3>Benachrichtigungen</h3>
<h3>{t('notificationBell.benachrichtigungen')}</h3>
{visibleNotifications.some(n => n.status === 'unread') && (
<button
className={styles.markAllRead}
@ -170,7 +173,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
{/* Content */}
<div className={styles.content}>
{loading && visibleNotifications.length === 0 && (
<div className={styles.loading}>Lade...</div>
<div className={styles.loading}>{t('notificationBell.lade')}</div>
)}
{error && (
@ -180,7 +183,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
{!loading && !error && visibleNotifications.length === 0 && (
<div className={styles.empty}>
<FaBell className={styles.emptyIcon} />
<p>Keine Benachrichtigungen</p>
<p>{t('notificationBell.keineBenachrichtigungen')}</p>
</div>
)}
@ -250,7 +253,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
<button
className={styles.dismissButton}
onClick={(e) => handleDismiss(notification, e)}
aria-label="Schliessen"
aria-label={t('notificationBell.schliessen')}
>
<FaTimes />
</button>

View file

@ -3,6 +3,8 @@ import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api';
import OnboardingWizard from './OnboardingWizard';
import { useLanguage } from '../providers/language/LanguageContext';
interface OnboardingStep {
id: string;
label: string;
@ -45,6 +47,7 @@ function _hideOnboarding(): void {
}
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
const { t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const [hidden, setHidden] = useState(() => _isOnboardingHidden());
@ -94,22 +97,22 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
onboardingSteps.push({
id: 'mandate',
label: 'Mandant einrichten',
label: t('onboardingAssistant.setupMandate'),
description: hasAdminMandate
? 'Dein Mandant ist eingerichtet.'
: hasFeature
? 'Du bist Mitglied eines Mandanten.'
: 'Erstelle deinen Arbeitsbereich.',
? t('onboardingAssistant.duBistMitgliedEinesMandanten')
: t('onboardingAssistant.erstelleDeinenArbeitsbereich'),
completed: mandateStepDone,
action: mandateStepDone ? undefined : () => setShowWizard(true),
});
onboardingSteps.push({
id: 'feature',
label: 'Erstes Feature aktivieren',
label: t('onboardingAssistant.activateFirstFeature'),
description: hasFeature
? 'Du hast aktive Features.'
: 'Aktiviere dein erstes Feature im Store.',
? t('onboardingAssistant.duHastAktiveFeatures')
: t('onboardingAssistant.aktiviereDeinErstesFeatureIm'),
completed: hasFeature,
action: hasFeature ? undefined : () => navigate('/store'),
});
@ -123,10 +126,10 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
onboardingSteps.push({
id: 'connection',
label: 'Erste Datenquelle einbinden',
label: t('onboardingAssistant.connectFirstDataSource'),
description: hasConnection
? 'Du hast Verbindungen eingerichtet.'
: 'Verbinde deine erste Datenquelle.',
? t('onboardingAssistant.duHastVerbindungenEingerichtet')
: t('onboardingAssistant.verbindeDeineErsteDatenquelle'),
completed: hasConnection,
action: hasConnection ? undefined : () => navigate('/basedata/connections'),
});
@ -144,10 +147,10 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
const chatAction = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined;
onboardingSteps.push({
id: 'chat',
label: 'Ersten AI-Chat starten',
label: t('onboardingAssistant.startFirstAiChat'),
description: hasChat
? 'Du hast bereits Chats gestartet.'
: 'Starte deinen ersten Chat mit dem AI-Assistenten.',
? t('onboardingAssistant.duHastBereitsChatsGestartet')
: t('onboardingAssistant.starteDeinenErstenChatMit'),
completed: hasChat,
action: hasChat ? undefined : chatAction,
});
@ -210,7 +213,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Willkommen bei PowerOn</h3>
<h3 style={{ margin: 0, fontSize: '1rem' }}>{t('onboardingAssistant.willkommenBeiPoweron')}</h3>
<p style={{ margin: '4px 0 0', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{completedCount} von {steps.length} Schritten abgeschlossen
</p>

View file

@ -1,12 +1,15 @@
import React, { useState } from 'react';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
interface OnboardingWizardProps {
onComplete: () => void;
onDismiss: () => void;
}
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
const { t } = useLanguage();
const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D');
const [mandateName, setMandateName] = useState('');
const [loading, setLoading] = useState(false);
@ -21,7 +24,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
companyName: mandateName.trim() || undefined,
});
if (res.data?.alreadyProvisioned) {
setError('Du hast bereits einen Mandanten mit Admin-Zugang.');
setError(t('onboardingWizard.duHastBereitsEinenMandanten'));
return;
}
window.dispatchEvent(new CustomEvent('features-changed'));
@ -43,7 +46,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Mandant erstellen</h2>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>{t('onboardingWizard.mandantErstellen')}</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
</p>
@ -57,7 +60,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<input type="radio" name="plan" checked={planKey === 'TRIAL_7D'}
onChange={() => setPlanKey('TRIAL_7D')} />
<div>
<strong>Kostenlos testen</strong>
<strong>{t('onboardingWizard.kostenlosTesten')}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
7 Tage gratis, danach flexibel upgraden
</div>
@ -72,7 +75,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<input type="radio" name="plan" checked={planKey === 'STANDARD_MONTHLY'}
onChange={() => setPlanKey('STANDARD_MONTHLY')} />
<div>
<strong>Standard (Monatlich)</strong>
<strong>{t('onboardingWizard.standardMonatlich')}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Team-Workspace mit vollem Funktionsumfang
</div>
@ -87,7 +90,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<input
type="text" value={mandateName}
onChange={(e) => setMandateName(e.target.value)}
placeholder="z. B. Firmenname oder Projektname"
placeholder={t('onboardingWizard.zBFirmennameOderProjektname')}
style={{
width: '100%', padding: '10px 12px', borderRadius: '6px',
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
@ -111,7 +114,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1,
}}>
{loading ? 'Wird eingerichtet...' : 'Mandant erstellen'}
{loading ? t('onboardingWizard.wirdEingerichtet') : t('onboardingWizard.mandantErstellen')}
</button>
</div>
</div>

View file

@ -18,6 +18,8 @@ import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================
// TYPES & HELPERS
// ============================================================================
@ -109,14 +111,14 @@ interface ProviderSelectProps {
showLabel?: boolean;
}
export const ProviderSelect: React.FC<ProviderSelectProps> = ({
value,
export const ProviderSelect: React.FC<ProviderSelectProps> = ({ value,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
}) => {
const { t } = useLanguage();
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
@ -141,7 +143,7 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
disabled={disabled || loading}
className={styles.select}
>
<option value="">-- Auto --</option>
<option value="">{t('providerSelector.auto')}</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@ -177,6 +179,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
defaultExpanded = false,
excludeByDefault = [],
}) => {
const { t } = useLanguage();
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -279,7 +282,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
title="Provider auswählen"
title={t('providerSelector.providerAuswaehlen')}
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
@ -300,7 +303,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
</div>
{loading ? (
<div className={styles.loading}>Lade...</div>
<div className={styles.loading}>{t('providerSelector.lade')}</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
@ -343,8 +346,9 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
providers,
className,
}) => {
const { t } = useLanguage();
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
return <span className={styles.allProviders}>{t('providerSelector.alleProvider')}</span>;
}
return (

View file

@ -26,6 +26,8 @@ import {
} from '../../hooks/useRbacExportImport';
import styles from './RbacExportImport.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// =============================================================================
// TYPES
// =============================================================================
@ -41,26 +43,28 @@ interface RbacExportImportProps {
// IMPORT MODE OPTIONS
// =============================================================================
const IMPORT_MODES: { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] = [
{
value: 'merge',
label: 'Zusammenführen',
description: 'Bestehende Regeln aktualisieren, neue hinzufügen',
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: 'Nur hinzufügen',
description: 'Nur neue Regeln hinzufügen, bestehende nicht ändern',
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: 'Ersetzen',
description: 'Alle bestehenden Regeln löschen und ersetzen',
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
},
];
function _getImportModes(t: (key: string) => string): { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] {
return [
{
value: 'merge',
label: t('rbacExportImport.zusammenfuehren'),
description: t('rbacExportImport.bestehendeRegelnAktualisieren'),
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: t('rbacExportImport.nurHinzufuegen'),
description: t('rbacExportImport.nurNeueRegelnHinzufuegen'),
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: t('rbacExportImport.ersetzen'),
description: t('rbacExportImport.alleBestehendenRegelnLoeschen'),
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
},
];
}
// =============================================================================
// PREVIEW COMPONENT
@ -72,6 +76,7 @@ interface PreviewProps {
}
const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
const { t } = useLanguage();
return (
<div className={styles.preview}>
<div className={styles.previewHeader}>
@ -83,7 +88,7 @@ const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
<h5>Scope</h5>
<ul className={styles.previewList}>
<li><strong>Typ:</strong> {data.scope.type}</li>
{data.scope.mandateName && <li><strong>Mandant:</strong> {data.scope.mandateName}</li>}
{data.scope.mandateName && <li><strong>{t('rbacExportImport.mandant')}</strong> {data.scope.mandateName}</li>}
{data.scope.featureCode && <li><strong>Feature:</strong> {data.scope.featureCode}</li>}
</ul>
</div>
@ -130,6 +135,8 @@ interface ImportResultProps {
}
const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
const { t } = useLanguage();
const importModes = _getImportModes(t);
const isSuccess = result.status === 'success';
return (
@ -141,21 +148,21 @@ const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
<FaExclamationTriangle className={styles.resultIcon} />
)}
<h4 className={styles.resultTitle}>
{isSuccess ? 'Import erfolgreich' : 'Import fehlgeschlagen'}
{isSuccess ? t('rbacExportImport.importErfolgreich') : t('rbacExportImport.importFehlgeschlagen')}
</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.resultContent}>
<ul className={styles.resultStats}>
<li><strong>Modus:</strong> {IMPORT_MODES.find(m => m.value === result.mode)?.label}</li>
<li><strong>Rollen erstellt:</strong> {result.rolesCreated}</li>
<li><strong>Rollen aktualisiert:</strong> {result.rolesUpdated}</li>
<li><strong>Regeln erstellt:</strong> {result.rulesCreated}</li>
<li><strong>Regeln aktualisiert:</strong> {result.rulesUpdated}</li>
<li><strong>Modus:</strong> {importModes.find(m => m.value === result.mode)?.label}</li>
<li><strong>{t('rbacExportImport.rollenErstellt')}</strong> {result.rolesCreated}</li>
<li><strong>{t('rbacExportImport.rollenAktualisiert')}</strong> {result.rolesUpdated}</li>
<li><strong>{t('rbacExportImport.regelnErstellt')}</strong> {result.rulesCreated}</li>
<li><strong>{t('rbacExportImport.regelnAktualisiert')}</strong> {result.rulesUpdated}</li>
</ul>
{result.errors && result.errors.length > 0 && (
<div className={styles.resultErrors}>
<h5>Fehler:</h5>
<h5>{t('rbacExportImport.fehler')}</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
@ -178,6 +185,8 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
isGlobal = false,
featureCode,
}) => {
const { t } = useLanguage();
const importModes = _getImportModes(t);
const {
exporting,
importing,
@ -332,7 +341,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
) : (
<>
<FaUpload className={styles.fileIcon} />
<span>JSON-Datei auswählen oder hier ablegen</span>
<span>{t('rbacExportImport.jsondateiAuswaehlenOderHierAblegen')}</span>
</>
)}
</label>
@ -340,7 +349,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
<button
className={styles.clearButton}
onClick={handleClearImport}
title="Datei entfernen"
title={t('rbacExportImport.dateiEntfernen')}
>
<FaTrash />
</button>
@ -358,7 +367,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
{importData && (
<div className={styles.importInfo}>
<div className={styles.importStats}>
<span><strong>Rollen:</strong> {importData.roles.length}</span>
<span><strong>{t('rbacExportImport.rollen')}</strong> {importData.roles.length}</span>
<span><strong>Regeln:</strong> {importData.accessRules.length}</span>
<span><strong>Quelle:</strong> {importData.scope.type}</span>
</div>
@ -376,7 +385,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
<div className={styles.importModeSection}>
<h4 className={styles.importModeTitle}>Import-Modus</h4>
<div className={styles.importModes}>
{IMPORT_MODES.map(mode => (
{importModes.map(mode => (
<label
key={mode.value}
className={`${styles.importModeOption} ${importMode === mode.value ? styles.selected : ''}`}

View file

@ -4,6 +4,8 @@ import { BaseTextFieldProps } from '../TextField/TextFieldTypes';
import { autocompleteAddress, AddressSuggestion } from '../../../api/realEstateApi';
import styles from './AddressAutocomplete.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface AddressAutocompleteProps extends BaseTextFieldProps {
onSelect?: (suggestion: AddressSuggestion) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@ -12,8 +14,7 @@ interface AddressAutocompleteProps extends BaseTextFieldProps {
maxSuggestions?: number;
}
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value = '',
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
onChange,
onSelect,
placeholder,
@ -34,6 +35,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
maxSuggestions = 10,
...props
}) => {
const { t } = useLanguage();
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
@ -274,7 +276,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
<ul ref={suggestionsRef} className={styles.suggestionsList}>
{isLoading && (
<li className={styles.suggestionItem}>
<span className={styles.loadingText}>Suche Adressen...</span>
<span className={styles.loadingText}>{t('addressAutocomplete.sucheAdressen')}</span>
</li>
)}
{!isLoading && autocompleteError && (
@ -284,7 +286,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
)}
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
<li className={styles.suggestionItem}>
<span className={styles.noResultsText}>Keine Adressen gefunden</span>
<span className={styles.noResultsText}>{t('addressAutocomplete.keineAdressenGefunden')}</span>
</li>
)}
{!isLoading && suggestions.map((suggestion, index) => (

View file

@ -1,6 +1,8 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import styles from './AutoScroll.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface AutoScrollProps {
/**
* Children to render inside the scrollable container
@ -30,12 +32,12 @@ export interface AutoScrollProps {
* AutoScroll component that automatically scrolls to the bottom when new content is added,
* unless the user has scrolled up. Shows a button when user has scrolled up.
*/
const AutoScroll: React.FC<AutoScrollProps> = ({
children,
const AutoScroll: React.FC<AutoScrollProps> = ({ children,
className = '',
scrollDependency,
threshold = 100
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const [showNewMessageButton, setShowNewMessageButton] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
@ -148,7 +150,7 @@ const AutoScroll: React.FC<AutoScrollProps> = ({
<button
className={styles.scrollToBottomButton}
onClick={handleNewMessageClick}
aria-label="Scroll to bottom"
aria-label={t('autoScroll.scrollToBottom')}
>
<span className={styles.scrollArrow}></span>
</button>

View file

@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { FaChevronDown, FaChevronUp, FaFilePdf, FaRuler } from 'react-icons/fa';
import styles from './BauvorschriftenSection.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface BauvorschriftenZone {
zonenbezeichnung: string;
ausnuetzungsziffer?: number;
@ -20,6 +22,7 @@ export interface BauvorschriftenSectionProps {
}
export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({ bauvorschriften }) => {
const { t } = useLanguage();
const [isExpanded, setIsExpanded] = useState(true);
return (
@ -39,7 +42,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
<div className={styles.bauvorschriftenGrid}>
{bauvorschriften.ausnuetzungsziffer !== undefined && bauvorschriften.ausnuetzungsziffer !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Ausnützungsziffer:</span>
<span className={styles.label}>{t('bauvorschriftenSection.ausnuetzungsziffer')}</span>
<span className={styles.value}>{bauvorschriften.ausnuetzungsziffer}%</span>
</div>
)}
@ -51,7 +54,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
)}
{bauvorschriften.gebaeudelaengeMax !== undefined && bauvorschriften.gebaeudelaengeMax !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Gebäudelänge max:</span>
<span className={styles.label}>{t('bauvorschriftenSection.gebaeudelaengeMax')}</span>
<span className={styles.value}>{bauvorschriften.gebaeudelaengeMax} m</span>
</div>
)}
@ -63,19 +66,19 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
)}
{bauvorschriften.mehrlaengenzuschlag && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Mehrlängenzuschlag:</span>
<span className={styles.label}>{t('bauvorschriftenSection.mehrlaengenzuschlag')}</span>
<span className={styles.value}>{bauvorschriften.mehrlaengenzuschlag}</span>
</div>
)}
{bauvorschriften.hoechstmassMax !== undefined && bauvorschriften.hoechstmassMax !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Höchstmass max:</span>
<span className={styles.label}>{t('bauvorschriftenSection.hoechstmassMax')}</span>
<span className={styles.value}>{bauvorschriften.hoechstmassMax} m</span>
</div>
)}
{bauvorschriften.fassadenhoehe && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Fassadenhöhe:</span>
<span className={styles.label}>{t('bauvorschriftenSection.fassadenhoehe')}</span>
<span className={styles.value}>{bauvorschriften.fassadenhoehe}</span>
</div>
)}

View file

@ -12,6 +12,8 @@ import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
onAction?: (file: WorkflowFile) => Promise<void> | void;
@ -67,6 +69,7 @@ export function ConnectedFilesList({
workflowId: _workflowId,
emptyMessage = 'No files connected to this workflow'
}: ConnectedFilesListProps) {
const { t } = useLanguage();
// Combine workflow files and pending files, deduplicating by fileId
const allFiles = useMemo(() => {
const fileMap = new Map<string, WorkflowFile>();
@ -181,7 +184,7 @@ export function ConnectedFilesList({
return (
<div className={styles.container}>
<div className={styles.header}>
<h3 className={styles.title}>Connected Files</h3>
<h3 className={styles.title}>{t('connectedFilesList.connectedFiles')}</h3>
<span className={styles.count}>({allFiles.length})</span>
</div>
<div className={styles.fileList}>
@ -228,14 +231,14 @@ export function ConnectedFilesList({
cursor: onAttach ? 'pointer' : 'default',
userSelect: 'none' // Prevent text selection on click
}}
title={onAttach ? (isPendingFile ? 'Click to detach from next message' : 'Click to attach for next message') : undefined}
title={onAttach ? (isPendingFile ? t('connectedFilesList.clickToDetachFromNext') : t('connectedFilesList.clickToAttachForNext')) : undefined}
>
<div className={styles.fileInfo}>
<div className={styles.fileName} title={file.fileName}>
{file.fileName}
{onAttach && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#666' }}>
{isPendingFile ? '(click to detach)' : '(click to attach)'}
{isPendingFile ? t('connectedFilesList.clickToDetach') : t('connectedFilesList.clickToAttach')}
</span>
)}
</div>
@ -245,7 +248,7 @@ export function ConnectedFilesList({
</span>
{file.source && (
<span className={styles.fileSource}>
{file.source === 'user_uploaded' ? 'Uploaded' : 'AI Created'}
{file.source === 'user_uploaded' ? t('connectedFilesList.uploaded') : t('connectedFilesList.aiCreated')}
</span>
)}
{isPendingFile && (

View file

@ -25,6 +25,8 @@ const TILE_LAYER_MINIMAL = L.tileLayer(
import icon from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
import { useLanguage } from '../../../providers/language/LanguageContext';
const DefaultIcon = L.icon({
iconUrl: icon,
shadowUrl: iconShadow,
@ -38,8 +40,7 @@ L.Marker.prototype.options.icon = DefaultIcon;
const SELECTED_STYLE = { color: '#3b82f6', weight: 3, fillColor: '#3b82f6', fillOpacity: 0.3 };
const MapViewLeaflet: React.FC<MapViewProps> = ({
parcels = [],
const MapViewLeaflet: React.FC<MapViewProps> = ({ parcels = [],
combinedOutline,
center,
zoomBounds,
@ -51,6 +52,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
showWfsParcels = false,
parcelsApiBaseUrl = ''
}) => {
const { t } = useLanguage();
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const layersRef = useRef<L.Layer[]>([]);
@ -192,7 +194,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
if (ring && ring.length >= 3) {
const latLngs = ring.map(([x, y]) => toWgs84(x, y));
const polygon = L.polygon(latLngs, SELECTED_STYLE);
polygon.bindPopup('<div><strong>Ausgewählte Fläche</strong><br/><em>Zum Entfernen Parzelle im Panel nutzen</em></div>');
polygon.bindPopup(`<div><strong>${t('mapViewLeaflet.ausgewaehlteFlaeche')}</strong><br/><em>${t('mapViewLeaflet.zumEntfernenParzelleImPanel')}</em></div>`);
if (onParcelClick) {
polygon.on('click', () => onParcelClick('combined'));
}
@ -205,7 +207,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
if (ring && ring.length >= 3) {
const latLngs = ring.map(([x, y]) => toWgs84(x, y));
const polygon = L.polygon(latLngs, SELECTED_STYLE);
polygon.bindPopup('<div><strong>Ausgewählte Fläche</strong><br/><em>Zum Entfernen Parzelle im Panel nutzen</em></div>');
polygon.bindPopup(`<div><strong>${t('mapViewLeaflet.ausgewaehlteFlaeche')}</strong><br/><em>${t('mapViewLeaflet.zumEntfernenParzelleImPanel')}</em></div>`);
if (onParcelClick) {
polygon.on('click', () => onParcelClick('combined'));
}
@ -223,7 +225,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({
<div>
<strong>Parzelle ${parcel.number || parcel.id}</strong><br/>
${parcel.egrid ? `EGRID: ${parcel.egrid}<br/>` : ''}
<em>Ausgewählt</em>
<em>{t('mapViewLeaflet.ausgewaehlt')}</em>
</div>
`);
if (onParcelClick) {

View file

@ -9,6 +9,8 @@ import { DocumentItem, ActionInfo } from '../MessageParts';
import { WorkflowFile } from '../../../../hooks/usePlayground';
import styles from '../Messages.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface ChatMessageProps {
message: Message;
showDocuments?: boolean;
@ -29,8 +31,7 @@ export interface ChatMessageProps {
/**
* Renders a single message in chat style (bubble UI)
*/
export const ChatMessage: React.FC<ChatMessageProps> = ({
message,
export const ChatMessage: React.FC<ChatMessageProps> = ({ message,
showDocuments = true,
renderDocument,
onFileDelete,
@ -45,6 +46,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
onMessageDelete,
deletingMessages
}) => {
const { t } = useLanguage();
const isUser = message.role?.toLowerCase() === 'user';
const isError = message.actionProgress === 'fail' || message.actionProgress === 'error';
const messageClass = isUser ? styles.messageUser : styles.messageAssistant;
@ -79,7 +81,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
{isError && (
<div className={styles.errorIndicator}>
<FaExclamationTriangle className={styles.errorIcon} />
<span>Aktion fehlgeschlagen</span>
<span>{t('chatMessage.aktionFehlgeschlagen')}</span>
</div>
)}
@ -199,7 +201,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
<button
onClick={handleConfirmDelete}
className={styles.messageDeleteConfirmBtn}
title="Löschen bestätigen"
title={t('chatMessage.loeschenBestaetigen')}
disabled={isDeleting}
>
<IoIosCheckmark />
@ -207,7 +209,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
<button
onClick={handleCancelDelete}
className={styles.messageDeleteCancelBtn}
title="Abbrechen"
title={t('chatMessage.abbrechen')}
disabled={isDeleting}
>
<IoIosClose />
@ -217,7 +219,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
<button
onClick={handleDeleteClick}
className={styles.messageDeleteBtn}
title="Nachricht löschen"
title={t('chatMessage.nachrichtLoeschen')}
disabled={isDeleting}
>
<IoIosTrash />

View file

@ -7,6 +7,8 @@ import api from '../../../api';
import { ContentPreview } from '../../ContentPreview';
import styles from './ParcelInfoPanel.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface SelectionSummary {
total_area_m2?: number;
bauzonen?: Array<{ bauzone: string; parcels: any[]; area_m2: number }>;
@ -114,14 +116,14 @@ export interface BZOInformationResponse {
warnings: string[];
}
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
isOpen,
const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
onClose,
parcels,
selectionSummary,
onRemoveParcel,
instanceId
}) => {
const { t } = useLanguage();
const [parcelDocs, setParcelDocs] = useState<Record<string, ParcelDocument[]>>({});
const [docsLoading, setDocsLoading] = useState<Record<string, boolean>>({});
const [docsError, setDocsError] = useState<Record<string, string>>({});
@ -225,7 +227,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<div className={styles.content}>
{selectionSummary?.total_area_m2 != null && (
<div className={styles.aggregatedSection}>
<h3 className={styles.aggregatedTitle}>Gesamtfläche</h3>
<h3 className={styles.aggregatedTitle}>{t('parcelInfoPanel.gesamtflaeche')}</h3>
<p className={styles.aggregatedValue}>
{selectionSummary.total_area_m2.toFixed(2)} m²
{selectionSummary.total_area_m2 >= 10000 && (
@ -264,7 +266,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<button
className={styles.removeButton}
onClick={() => onRemoveParcel(parcelData.parcel.id)}
title="Parzelle entfernen"
title={t('parcelInfoPanel.parzelleEntfernen')}
>
<FaTrash />
</button>
@ -315,7 +317,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{parcelData.parcel.area_m2 !== undefined && (
<div className={styles.infoItem}>
<span className={styles.label}>Fläche:</span>
<span className={styles.label}>{t('parcelInfoPanel.flaeche')}</span>
<span className={styles.value}>
{parcelData.parcel.area_m2.toFixed(2)} m²
{parcelData.parcel.area_m2 >= 10000 && (
@ -328,7 +330,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{parcelData.parcel.realestate_type && (
<div className={styles.infoItem}>
<span className={styles.label}>Grundstückstyp:</span>
<span className={styles.label}>{t('parcelInfoPanel.grundstueckstyp')}</span>
<span className={styles.value}>{parcelData.parcel.realestate_type}</span>
</div>
)}
@ -342,7 +344,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<div className={styles.bzoSection}>
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
{docsLoading[parcelData.parcel.id] && (
<p className={styles.bzoHint}>Dokumente werden geladen...</p>
<p className={styles.bzoHint}>{t('parcelInfoPanel.dokumenteWerdenGeladen')}</p>
)}
{docsError[parcelData.parcel.id] && (
<p className={styles.bzoError}>{docsError[parcelData.parcel.id]}</p>
@ -360,7 +362,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
fileName: doc.fileName,
mimeType: doc.mimeType
})}
title="Dokument öffnen"
title={t('parcelInfoPanel.dokumentOeffnen')}
>
<FaEye /> Öffnen
</button>
@ -378,7 +380,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
areaForMach
)}
disabled={extractLoading[parcelData.parcel.id]}
title="Inhalt mit LangGraph extrahieren (inkl. Machbarkeitsstudie)"
title={t('parcelInfoPanel.inhaltMitLanggraphExtrahierenInkl')}
>
{extractLoading[parcelData.parcel.id] ? (
<FaSync className={styles.spin} />
@ -402,7 +404,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<>
{fakten.length > 0 && (
<div className={styles.bzoFakten}>
<span className={styles.label}>Fakten aus BZO</span>
<span className={styles.label}>{t('parcelInfoPanel.faktenAusBzo')}</span>
<ul className={styles.rulesList}>
{fakten.map((row: { item: string; value: string; source?: string }, i: number) => (
<li key={i}>
@ -419,7 +421,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{vorschlaege.length > 0 && (
<div className={styles.bzoSuggestions}>
<span className={styles.label}>Vorschläge</span>
<span className={styles.label}>{t('parcelInfoPanel.vorschlaege')}</span>
<ul className={styles.rulesList}>
{vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => {
if (typeof row === 'string') return <li key={i}>{row}</li>;
@ -438,7 +440,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{zusatzinfo.length > 0 && (
<div className={styles.bzoZusatzinfo}>
<span className={styles.label}>Weiterführende Bestimmungen</span>
<span className={styles.label}>{t('parcelInfoPanel.weiterfuehrendeBestimmungen')}</span>
<div className={styles.zusatzinfoList}>
{zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => (
<details key={i} className={styles.zusatzinfoItem}>
@ -489,7 +491,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
})()}
{import.meta.env.DEV && (
<details className={styles.zoneDetails}>
<summary className={styles.zoneSummary}>Details anzeigen</summary>
<summary className={styles.zoneSummary}>{t('parcelInfoPanel.detailsAnzeigen')}</summary>
<pre className={styles.zoneData}>
{JSON.stringify(parcelData.parcel.zone, null, 2)}
</pre>
@ -530,7 +532,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<button
className={styles.removeButton}
onClick={() => onRemoveParcel(parcelData.parcel.id)}
title="Parzelle entfernen"
title={t('parcelInfoPanel.parzelleEntfernen')}
>
<FaTrash />
</button>
@ -581,7 +583,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{parcelData.parcel.area_m2 !== undefined && (
<div className={styles.infoItem}>
<span className={styles.label}>Fläche:</span>
<span className={styles.label}>{t('parcelInfoPanel.flaeche')}</span>
<span className={styles.value}>
{parcelData.parcel.area_m2.toFixed(2)} m²
{parcelData.parcel.area_m2 >= 10000 && (
@ -594,7 +596,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{parcelData.parcel.realestate_type && (
<div className={styles.infoItem}>
<span className={styles.label}>Grundstückstyp:</span>
<span className={styles.label}>{t('parcelInfoPanel.grundstueckstyp')}</span>
<span className={styles.value}>{parcelData.parcel.realestate_type}</span>
</div>
)}
@ -608,7 +610,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<div className={styles.bzoSection}>
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
{docsLoading[parcelData.parcel.id] && (
<p className={styles.bzoHint}>Dokumente werden geladen...</p>
<p className={styles.bzoHint}>{t('parcelInfoPanel.dokumenteWerdenGeladen')}</p>
)}
{docsError[parcelData.parcel.id] && (
<p className={styles.bzoError}>{docsError[parcelData.parcel.id]}</p>
@ -626,7 +628,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
fileName: doc.fileName,
mimeType: doc.mimeType
})}
title="Dokument öffnen"
title={t('parcelInfoPanel.dokumentOeffnen')}
>
<FaEye /> Öffnen
</button>
@ -644,7 +646,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
areaForMachFlat
)}
disabled={extractLoading[parcelData.parcel.id]}
title="Inhalt mit LangGraph extrahieren (inkl. Machbarkeitsstudie)"
title={t('parcelInfoPanel.inhaltMitLanggraphExtrahierenInkl')}
>
{extractLoading[parcelData.parcel.id] ? (
<FaSync className={styles.spin} />
@ -668,7 +670,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
<>
{fakten.length > 0 && (
<div className={styles.bzoFakten}>
<span className={styles.label}>Fakten aus BZO</span>
<span className={styles.label}>{t('parcelInfoPanel.faktenAusBzo')}</span>
<ul className={styles.rulesList}>
{fakten.map((row: { item: string; value: string; source?: string }, i: number) => (
<li key={i}>
@ -685,7 +687,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{vorschlaege.length > 0 && (
<div className={styles.bzoSuggestions}>
<span className={styles.label}>Vorschläge</span>
<span className={styles.label}>{t('parcelInfoPanel.vorschlaege')}</span>
<ul className={styles.rulesList}>
{vorschlaege.map((row: { item: string; value: string; is_section?: boolean } | string, i: number) => {
if (typeof row === 'string') return <li key={i}>{row}</li>;
@ -704,7 +706,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
)}
{zusatzinfo.length > 0 && (
<div className={styles.bzoZusatzinfo}>
<span className={styles.label}>Weiterführende Bestimmungen</span>
<span className={styles.label}>{t('parcelInfoPanel.weiterfuehrendeBestimmungen')}</span>
<div className={styles.zusatzinfoList}>
{zusatzinfo.map((art: { article_label: string; article_title: string; text: string; source?: string }, i: number) => (
<details key={i} className={styles.zusatzinfoItem}>
@ -753,7 +755,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({
})()}
{import.meta.env.DEV && (
<details className={styles.zoneDetails}>
<summary className={styles.zoneSummary}>Details anzeigen</summary>
<summary className={styles.zoneSummary}>{t('parcelInfoPanel.detailsAnzeigen')}</summary>
<pre className={styles.zoneData}>
{JSON.stringify(parcelData.parcel.zone, null, 2)}
</pre>
@ -835,12 +837,13 @@ const bzoMarkdownComponents: Record<string, React.ComponentType<MdComponentProps
};
export const BZOInformationDisplay: React.FC<BZOInformationDisplayProps> = ({ data }) => {
const { t } = useLanguage();
return (
<div className={styles.bzoContent}>
{/* Summary Section */}
{data.ai_summary && (
<div className={styles.bzoSubSection}>
<h5 className={styles.bzoSubTitle}>Zusammenfassung</h5>
<h5 className={styles.bzoSubTitle}>{t('parcelInfoPanel.zusammenfassung')}</h5>
<div className={styles.bzoSummary}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@ -1079,7 +1082,7 @@ export const BZOInformationDisplay: React.FC<BZOInformationDisplayProps> = ({ da
{/* Statistics */}
{data.extracted_content && (
<div className={styles.bzoSubSection}>
<h5 className={styles.bzoSubTitle}>Statistiken</h5>
<h5 className={styles.bzoSubTitle}>{t('parcelInfoPanel.statistiken')}</h5>
<div className={styles.bzoStats}>
<div className={styles.bzoStatItem}>
<span className={styles.bzoStatLabel}>Zonen:</span>

View file

@ -1,6 +1,8 @@
import React from 'react';
import styles from './Popup.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Action button interface
export interface PopupAction {
label: string;
@ -36,7 +38,8 @@ export function Popup({
closable = true,
actions = []
}: PopupProps) {
const { t } = useLanguage();
// Handle escape key
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@ -105,7 +108,7 @@ export function Popup({
<button
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
aria-label={t('popup.close')}
>
×
</button>

View file

@ -2,6 +2,8 @@ import { motion, AnimatePresence } from 'framer-motion';
import { FaCheckCircle, FaExclamationCircle, FaExclamationTriangle, FaInfoCircle, FaTimes } from 'react-icons/fa';
import styles from './Toast.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastData {
@ -31,6 +33,7 @@ const _getIcon = (type: ToastType) => {
};
export const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
const { t } = useLanguage();
return (
<motion.div
className={`${styles.toast} ${styles[toast.type]}`}
@ -50,7 +53,7 @@ export const Toast: React.FC<ToastProps> = ({ toast, onClose }) => {
<button
className={styles.closeButton}
onClick={() => onClose(toast.id)}
aria-label="Schließen"
aria-label={t('toast.schliessen')}
>
<FaTimes />
</button>

View file

@ -7,6 +7,8 @@ import {
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Field configuration interface for ViewForm
export interface ViewFieldConfig {
key: string;
@ -32,6 +34,8 @@ export function ViewForm<T extends Record<string, any>>({
// Format value based on field type
const formatValue = (field: ViewFieldConfig, value: any): string => {
const { t } = useLanguage();
// Use custom formatter if provided
if (field.formatter) {
return field.formatter(value);
@ -46,7 +50,7 @@ export function ViewForm<T extends Record<string, any>>({
if (field.type) {
// Boolean/Checkbox types
if (isCheckboxType(field.type)) {
return value ? 'Yes' : 'No';
return value ? t('viewForm.yes') : t('viewForm.no');
}
// Select/Enum types

View file

@ -2,6 +2,8 @@ import React, { useMemo } from 'react';
import { WorkflowStatusProps, WorkflowStatusType } from './WorkflowStatusTypes';
import styles from './WorkflowStatus.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const _STATUS_MAP: Record<string, WorkflowStatusType> = {
success: 'completed',
completed: 'completed',
@ -43,6 +45,7 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
isRunning,
latestStats
}) => {
const { t } = useLanguage();
// Use workflow status and round from API response, fallback to extracting from logs
const workflowStatus = useMemo(() => {
if (workflowStatusFromApi) {
@ -66,7 +69,7 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
{/* Status and Round Badges */}
<div className={styles.workflowStatus}>
{showSpinner && (
<div className={styles.spinner} aria-label="Workflow running" />
<div className={styles.spinner} aria-label={t('workflowStatus.workflowRunning')} />
)}
{workflowStatus.status && (
<span className={styles.statusBadge} data-status={workflowStatus.status}>

View file

@ -3,6 +3,8 @@ import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import styles from './ChatsTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface ChatItem {
id: string;
label: string;
@ -50,8 +52,7 @@ function _formatRelativeTime(dateStr?: string | number): string {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
const ChatsTab: React.FC<ChatsTabProps> = ({
context,
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat,
onDragStart,
activeWorkflowId,
@ -59,6 +60,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
onRenameChat,
onDeleteChat,
}) => {
const { t } = useLanguage();
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState('');
@ -307,7 +309,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
title="Löschen"
title={t('chatsTab.loeschen')}
>
🗑
</button>
@ -329,7 +331,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
return labels[code] || code;
};
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
if (loading) return <div className={styles.loading}>{t('chatsTab.ladeChats')}</div>;
return (
<div className={styles.chatsTab}>
@ -337,12 +339,12 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
<input
className={styles.search}
type="text"
placeholder="Suchen..."
placeholder={t('chatsTab.suchen')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title="Neuer Chat">
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('chatsTab.neuerChat')}>
+
</button>
)}
@ -434,7 +436,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({
{_allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
{filter === 'archived' ? t('chatsTab.keineArchiviertenChats') : t('chatsTab.keineAktivenChats')}
</div>
)}
</div>

View file

@ -6,6 +6,8 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext';
import styles from './FilesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface FileEntry {
id: string;
fileName: string;
@ -23,6 +25,7 @@ interface FilesTabProps {
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const { t } = useLanguage();
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
@ -230,7 +233,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
}
}, [_loadFiles]);
if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
if (loading) return <div className={styles.loading}>{t('filesTab.ladeDateien')}</div>;
return (
<div
@ -258,7 +261,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title="Upload files"
title={t('filesTab.uploadFiles')}
>
{uploading ? '...' : '+'}
</button>
@ -281,7 +284,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
<input
type="text"
placeholder="Dateien suchen..."
placeholder={t('filesTab.dateienSuchen')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
@ -322,7 +325,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
{searchQuery ? t('filesTab.keineDateienGefunden') : t('filesTab.keineDateienDragDropZum')}
</div>
)}
</div>

View file

@ -20,6 +20,8 @@ import api from '../../api';
import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/* ─── Types (inline, no external imports) ────────────────────────────── */
interface UdbDataSource {
@ -345,6 +347,7 @@ function _Spinner(): React.ReactElement {
/* ─── Component ──────────────────────────────────────────────────────── */
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) => {
const { t } = useLanguage();
const instanceId = context.instanceId;
/* ── Active sources (fetched internally) ── */
@ -850,14 +853,14 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
fontSize: 13, padding: '0 2px', lineHeight: 1,
opacity: ds.neutralize ? 1 : 0.35,
}}
title={ds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
title={ds.neutralize ? t('sourcesTab.neutralizeOnClickToDeactivate') : t('sourcesTab.neutralizeOffClickToActivate')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
title={t('sourcesTab.entfernen')}
>
{'\u2715'}
</button>
@ -953,7 +956,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
<button
onClick={() => { group.items.forEach(fds => _removeFeatureDataSource(fds.id)); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title="Remove all tables for this record"
title={t('sourcesTab.removeAllTablesForThis')}
>
{'\u2715'}
</button>
@ -983,14 +986,14 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
<button
onClick={() => _toggleFeatureNeutralize(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? 'Neutralize: ON' : 'Neutralize: OFF'}
title={fds.neutralize ? t('sourcesTab.neutralizeOn') : t('sourcesTab.neutralizeOff')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
title="Remove"
title={t('sourcesTab.remove')}
>
{'\u2715'}
</button>
@ -1026,14 +1029,14 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
<button
onClick={() => _toggleFeatureNeutralize(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? 'Neutralize: ON' : 'Neutralize: OFF'}
title={fds.neutralize ? t('sourcesTab.neutralizeOn') : t('sourcesTab.neutralizeOff')}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
title={t('sourcesTab.entfernen')}
>
{'\u2715'}
</button>
@ -1110,6 +1113,7 @@ interface _TreeNodeViewProps {
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const hasChildren = node.type !== 'file';
const chevron = hasChildren
@ -1163,13 +1167,13 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
title="Add as data source"
title={t('sourcesTab.addAsDataSource')}
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{canAdd && alreadyAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('sourcesTab.alreadyAdded')}>
{'\u2713'}
</span>
)}
@ -1396,6 +1400,7 @@ interface _FeatureTableRowProps {
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureNode, table, onAdd, isAdded, isAdding,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const tableLabel = table.label?.en || table.label?.de || table.tableName;
@ -1426,13 +1431,13 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title="Add as feature data source"
title={t('sourcesTab.addAsFeatureDataSource')}
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('sourcesTab.alreadyAdded')}>
{'\u2713'}
</span>
)}
@ -1537,6 +1542,7 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
featureNode: _featureNode, record, childTables, allTables: _allTables,
onToggle, onAdd, isAdded, isAdding,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = record.expanded ? '\u25BE' : '\u25B8';
@ -1572,13 +1578,13 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1, flexShrink: 0,
}}
title="Add all tables for this record"
title={t('sourcesTab.addAllTablesForThis')}
>
{isAdding ? '...' : '+ Add'}
</button>
)}
{isAdded && (
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title="Already added">
<span style={{ fontSize: 10, color: '#4caf50', flexShrink: 0 }} title={t('sourcesTab.alreadyAdded')}>
{'\u2713'}
</span>
)}

View file

@ -83,6 +83,8 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
// Helper function to resolve node name
const resolveNodeName = (pathSegment: string, fullPath: string, page?: GenericPageData): string => {
const { t } = useLanguage();
if (page) {
return resolveLanguageText(page.name, t);
}

View file

@ -19,23 +19,25 @@ export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
// ACCESS LEVEL LABELS
// =============================================================================
export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [
{ value: 'n', label: 'Keine', color: '#e53e3e' },
{ value: 'm', label: 'Eigene', color: '#d69e2e' },
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
{ value: 'a', label: 'Alle', color: '#38a169' },
];
export function _getAccessLevelOptions(t: (key: string) => string): { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] {
return [
{ value: 'n', label: t('useAccessRules.keine'), color: '#e53e3e' },
{ value: 'm', label: t('useAccessRules.eigene'), color: '#d69e2e' },
{ value: 'g', label: t('useAccessRules.gruppe'), color: '#3182ce' },
{ value: 'a', label: t('useAccessRules.alle'), color: '#38a169' },
];
}
export const getAccessLevelLabel = (level: AccessLevel | null): string => {
if (!level) return '-';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.label || level;
const _accessLevelColors: Record<string, string> = {
n: '#e53e3e',
m: '#d69e2e',
g: '#3182ce',
a: '#38a169',
};
export const getAccessLevelColor = (level: AccessLevel | null): string => {
if (!level) return '#718096';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.color || '#718096';
return _accessLevelColors[level] || '#718096';
};
export interface AccessRule {

View file

@ -7,7 +7,8 @@
* // Render <ConfirmDialog /> once in the component tree.
*/
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
export interface ConfirmOptions {
title?: string;
@ -22,17 +23,18 @@ interface ConfirmState {
resolve: (value: boolean) => void;
}
const _defaults: Required<ConfirmOptions> = {
title: 'Bestätigung',
confirmLabel: 'Bestätigen',
cancelLabel: 'Abbrechen',
variant: 'primary',
};
export function useConfirm() {
const { t } = useLanguage();
const [state, setState] = useState<ConfirmState | null>(null);
const resolveRef = useRef<((v: boolean) => void) | null>(null);
const _defaults: Required<ConfirmOptions> = useMemo(() => ({
title: t('confirm.title'),
confirmLabel: t('confirm.confirmLabel'),
cancelLabel: t('confirm.cancelLabel'),
variant: 'primary' as const,
}), [t]);
const confirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
@ -42,7 +44,7 @@ export function useConfirm() {
resolve,
});
});
}, []);
}, [_defaults]);
const _handleConfirm = useCallback(() => {
resolveRef.current?.(true);

View file

@ -1,12 +1,3 @@
export type {
Workflow,
WorkflowMessage,
WorkflowLog,
StartWorkflowRequest,
StartWorkflowResponse,
ChatDataResponse
} from '../api/workflowApi';
export interface UserInputRequest {
input: string;
files?: any[];

View file

@ -8,7 +8,8 @@
* // Render <PromptDialog /> once in the component tree.
*/
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
export interface PromptOptions {
title?: string;
@ -25,20 +26,21 @@ interface PromptState {
resolve: (value: string | null) => void;
}
const _defaults: Required<PromptOptions> = {
title: 'Eingabe',
confirmLabel: 'OK',
cancelLabel: 'Abbrechen',
placeholder: '',
defaultValue: '',
variant: 'primary',
};
export function usePrompt() {
const { t } = useLanguage();
const [state, setState] = useState<PromptState | null>(null);
const resolveRef = useRef<((v: string | null) => void) | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const _defaults: Required<PromptOptions> = useMemo(() => ({
title: t('prompt.title'),
confirmLabel: t('prompt.confirmLabel'),
cancelLabel: t('prompt.cancelLabel'),
placeholder: '',
defaultValue: '',
variant: 'primary' as const,
}), [t]);
const prompt = useCallback((message: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
@ -48,7 +50,7 @@ export function usePrompt() {
resolve,
});
});
}, []);
}, [_defaults]);
const _handleConfirm = useCallback(() => {
const val = inputRef.current?.value ?? '';

View file

@ -1,23 +1,82 @@
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import {
deleteWorkflowApi,
deleteWorkflowsApi,
updateWorkflowApi,
fetchWorkflows as fetchWorkflowsApi,
fetchWorkflow as fetchWorkflowByIdApi,
fetchAttributes as fetchAttributesApi,
startWorkflowApi,
stopWorkflowApi,
deleteMessageApi,
deleteFileFromMessageApi,
type Workflow,
type AttributeDefinition,
type StartWorkflowRequest
import { fetchAttributes as fetchAttributesApi } from '../api/attributesApi';
import type { AttributeDefinition, ApiRequestFunction } from '../api/attributesApi';
import {
fetchWorkflows as fetchWorkflowsFromApi,
fetchWorkflow as fetchWorkflowFromApi,
deleteWorkflow as deleteWorkflowFromApi,
updateWorkflow as updateWorkflowFromApi,
} from '../api/workflowApi';
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
import { usePermissions, type UserPermissions } from './usePermissions';
export type StartWorkflowRequest = Record<string, unknown>;
function _workflowsInstanceIdFromBaseUrl(apiBaseUrl: string | undefined): string | null {
if (!apiBaseUrl) return null;
const m = apiBaseUrl.match(/^\/api\/workflows\/([^/]+)$/);
return m ? m[1] : null;
}
async function _deleteWorkflowsSequential(
request: ApiRequestFunction,
instanceId: string,
workflowIds: string[],
) {
for (const id of workflowIds) {
await deleteWorkflowFromApi(request, instanceId, id);
}
}
async function startWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowData: StartWorkflowRequest,
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' },
) {
return await request({
url: `/api/workflows/${instanceId}/execute`,
method: 'post',
data: {
workflowId: options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId,
payload: workflowData,
},
});
}
async function stopWorkflowApi(request: ApiRequestFunction, instanceId: string, workflowId: string) {
await request({
url: `/api/workspace/${instanceId}/${workflowId}/stop`,
method: 'post',
});
}
async function deleteMessageApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
messageId: string,
) {
await request({
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}`,
method: 'delete',
});
}
async function deleteFileFromMessageApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
messageId: string,
fileId: string,
) {
await request({
url: `/api/workspace/${instanceId}/workflows/${workflowId}/messages/${messageId}/files/${fileId}`,
method: 'delete',
});
}
// Workflow interface matching backend
export interface UserWorkflow {
id: string;
@ -28,8 +87,8 @@ export interface UserWorkflow {
[key: string]: any; // Allow additional properties
}
// Re-export AttributeDefinition from workflowApi
export type { AttributeDefinition } from '../api/workflowApi';
// Re-export AttributeDefinition from attributesApi
export type { AttributeDefinition } from '../api/attributesApi';
// Attribute option interface (from backend)
export interface AttributeOption {
@ -65,7 +124,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Workflow[]>();
const { request, isLoading: loading, error } = useApiRequest<null, unknown>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
@ -103,38 +162,33 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
try {
const requestParams: any = {};
// Build pagination object if provided
if (!apiBaseUrl) {
console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)');
return;
}
const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
if (!instanceId) {
console.error('useUserWorkflows: could not parse instanceId from apiBaseUrl');
return;
}
let listParams: { pagination?: Record<string, unknown> } | undefined = undefined;
if (params) {
const paginationObj: any = {};
const paginationObj: Record<string, unknown> = {};
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);
listParams = { pagination: paginationObj };
}
}
let data: any;
if (!apiBaseUrl) {
console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)');
return;
}
const url = `${apiBaseUrl}/workflows`;
if (Object.keys(requestParams).length > 0) {
data = await request({ url, method: 'get', params: requestParams });
} else {
data = await fetchWorkflowsApi(request, undefined, apiBaseUrl);
}
const data: unknown = await fetchWorkflowsFromApi(request, instanceId, listParams);
// Handle paginated response
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
if (data && typeof data === 'object' && data !== null && 'items' in data) {
const d = data as { items?: unknown; pagination?: unknown };
const items = Array.isArray(d.items) ? d.items : [];
// Map API response to our frontend model
const mappedWorkflows = items.map((apiWorkflow: any): UserWorkflow => {
return {
@ -147,8 +201,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
};
});
setWorkflows(mappedWorkflows);
if (data.pagination) {
setPagination(data.pagination);
if (d.pagination && typeof d.pagination === 'object') {
setPagination(d.pagination as any);
}
} else {
// Handle non-paginated response (backward compatibility)
@ -192,8 +246,10 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
// Fetch a single workflow by ID
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
try {
const workflow = await fetchWorkflowByIdApi(request, workflowId, apiBaseUrl);
return workflow as UserWorkflow | null;
const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
if (!instanceId) return null;
const workflow = await fetchWorkflowFromApi(request, instanceId, workflowId);
return workflow as unknown as UserWorkflow | null;
} catch (error: any) {
console.error('Error fetching workflow by ID:', error);
return null;
@ -379,7 +435,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
// Workflow operations hook - pass instanceId and featureCode when in feature context for feature-scoped API
export function useWorkflowOperations(options?: { instanceId?: string; featureCode?: string }) {
const apiBaseUrl = getWorkflowApiBaseUrl(options?.instanceId, options?.featureCode);
const instanceId = options?.instanceId;
const [startingWorkflow, setStartingWorkflow] = useState(false);
const [stoppingWorkflows, setStoppingWorkflows] = useState<Set<string>>(new Set());
const [deletingWorkflows, setDeletingWorkflows] = useState<Set<string>>(new Set());
@ -479,7 +535,10 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
workflowId,
setDeletingWorkflows,
setDeleteError,
() => deleteWorkflowApi(request, workflowId, apiBaseUrl),
() => {
if (!instanceId) throw new Error('instanceId required');
return deleteWorkflowFromApi(request, instanceId, workflowId);
},
{
default: 'Failed to delete workflow',
notFound: 'Workflow not found or has already been deleted.',
@ -514,8 +573,8 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
});
try {
// Delete workflows one by one since there's no bulk delete endpoint
await deleteWorkflowsApi(request, workflowIds, apiBaseUrl);
if (!instanceId) throw new Error('instanceId required');
await _deleteWorkflowsSequential(request, instanceId, workflowIds);
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
@ -550,7 +609,10 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
operationKey,
setDeletingMessages,
setDeleteMessageError,
() => deleteMessageApi(request, workflowId, messageId, apiBaseUrl),
() => {
if (!instanceId) throw new Error('instanceId required');
return deleteMessageApi(request, instanceId, workflowId, messageId);
},
{
default: 'Failed to delete message',
notFound: 'Message not found or has already been deleted.',
@ -569,7 +631,10 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
operationKey,
setDeletingFiles,
setDeleteFileError,
() => deleteFileFromMessageApi(request, workflowId, messageId, fileId, apiBaseUrl),
() => {
if (!instanceId) throw new Error('instanceId required');
return deleteFileFromMessageApi(request, instanceId, workflowId, messageId, fileId);
},
{
default: 'Failed to delete file',
notFound: 'File not found or has already been deleted.',
@ -583,7 +648,10 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
setEditingWorkflows(prev => new Set(prev).add(workflowId));
try {
const updatedWorkflow = await updateWorkflowApi(request, workflowId, updateData, apiBaseUrl);
if (!instanceId) throw new Error('instanceId required');
const updatedWorkflow = await updateWorkflowFromApi(request, instanceId, workflowId, {
label: updateData.name,
});
return { success: true, workflowData: updatedWorkflow };
} catch (error: any) {

View file

@ -11,16 +11,21 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
import styles from './FeatureLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// LOADING COMPONENT
// =============================================================================
const LoadingScreen: React.FC = () => (
<div className={styles.loadingContainer}>
<div className={styles.loadingSpinner} />
<p>Lade Feature-Daten...</p>
</div>
);
const LoadingScreen: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.loadingContainer}>
<div className={styles.loadingSpinner} />
<p>{t('ui.ladeFeaturedaten')}</p>
</div>
);
};
// =============================================================================
// ERROR COMPONENT
@ -31,16 +36,19 @@ interface ErrorScreenProps {
returnPath?: string;
}
const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' }) => (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<h2>Zugriff nicht möglich</h2>
<p>{message}</p>
<a href={returnPath} className={styles.errorLink}>
Zurück zur Übersicht
</a>
</div>
);
const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' }) => {
const { t } = useLanguage();
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<h2>{t('ui.zugriffNichtMoeglich')}</h2>
<p>{message}</p>
<a href={returnPath} className={styles.errorLink}>
Zurück zur Übersicht
</a>
</div>
);
};
// =============================================================================
// FEATURE LAYOUT

View file

@ -15,6 +15,8 @@ import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive'
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
@ -24,6 +26,8 @@ const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
// =============================================================================
const MainLayoutInner: React.FC = () => {
const { t } = useLanguage();
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
@ -60,7 +64,7 @@ const MainLayoutInner: React.FC = () => {
<button
className={styles.mobileBackdrop}
onClick={() => setIsMobileSidebarOpen(false)}
aria-label="Navigation schliessen"
aria-label={t('mainLayout.navigationSchliessen')}
/>
)}
@ -102,7 +106,7 @@ const MainLayoutInner: React.FC = () => {
<button
className={styles.mobileMenuButton}
onClick={() => setIsMobileSidebarOpen(true)}
aria-label="Navigation oeffnen"
aria-label={t('mainLayout.navigationOeffnen')}
>
</button>

View file

@ -234,7 +234,7 @@ export const AutomationsDashboardPage: React.FC = () => {
{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>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('automationsDashboard.runsNachStatus')}</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
<span

View file

@ -15,6 +15,8 @@ import { FaArrowRight, FaBuilding } from 'react-icons/fa';
import OnboardingAssistant from '../components/OnboardingAssistant';
import styles from './Dashboard.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// INSTANCE CARD
// =============================================================================
@ -53,6 +55,7 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, feature }) => {
// =============================================================================
export const DashboardPage: React.FC = () => {
const { t } = useLanguage();
const { dynamicBlock, loading } = useNavigation();
// Alle Mandate und deren Features/Instanzen aus der Navigation
@ -69,8 +72,8 @@ export const DashboardPage: React.FC = () => {
return (
<div className={styles.dashboard}>
<header className={styles.header}>
<h1>Übersicht</h1>
<p className={styles.subtitle}>Lade...</p>
<h1>{t('dashboard.uebersicht')}</h1>
<p className={styles.subtitle}>{t('dashboard.lade')}</p>
</header>
</div>
);
@ -79,7 +82,7 @@ export const DashboardPage: React.FC = () => {
return (
<div className={styles.dashboard}>
<header className={styles.header}>
<h1>Übersicht</h1>
<h1>{t('dashboard.uebersicht')}</h1>
{totalInstances > 0 && (
<p className={styles.subtitle}>
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.

View file

@ -51,6 +51,8 @@ import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } f
import styles from './FeatureView.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// PLACEHOLDER VIEWS (für nicht implementierte Features)
// =============================================================================
@ -63,39 +65,53 @@ const PlaceholderView: React.FC<{ title: string; description: string }> = ({ tit
);
// Chatworkflow Views
const ChatworkflowDashboard: React.FC = () => (
<PlaceholderView title="Workflow Dashboard" description="Übersicht der Workflows" />
);
const ChatworkflowDashboard: React.FC = () => {
const { t } = useLanguage();
return (
<PlaceholderView title={t('feature.workflowDashboard')} description={t('feature.uebersichtDerWorkflows')} />
);
};
const ChatworkflowRuns: React.FC = () => (
<PlaceholderView title="Runs" description="Workflow-Ausführungen" />
);
const ChatworkflowRuns: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title="Runs" description={t('feature.workflowausfuehrungen')} />;
};
const ChatworkflowFiles: React.FC = () => (
<PlaceholderView title="Dateien" description="Workflow-Dateien" />
);
const ChatworkflowFiles: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title={t('feature.dateien')} description="Workflow-Dateien" />;
};
// Chatbot Views
// ChatbotConversationsView is imported above
const ChatbotSettings: React.FC = () => (
<PlaceholderView title="Chatbot Einstellungen" description="Konfiguration des Chatbots" />
);
const ChatbotSettings: React.FC = () => {
const { t } = useLanguage();
return (
<PlaceholderView title={t('feature.chatbotEinstellungen')} description={t('feature.konfigurationDesChatbots')} />
);
};
// Generic/Fallback
const NotFound: React.FC = () => (
<div className={styles.notFound}>
<h2>Seite nicht gefunden</h2>
<p>Diese View existiert nicht oder wurde noch nicht implementiert.</p>
</div>
);
const NotFound: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.notFound}>
<h2>{t('feature.seiteNichtGefunden')}</h2>
<p>{t('feature.dieseViewExistiertNichtOder')}</p>
</div>
);
};
const AccessDenied: React.FC = () => (
<div className={styles.accessDenied}>
<h2>Zugriff verweigert</h2>
<p>Du hast keine Berechtigung für diese Ansicht.</p>
</div>
);
const AccessDenied: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.accessDenied}>
<h2>{t('feature.zugriffVerweigert')}</h2>
<p>{t('feature.duHastKeineBerechtigungFuer')}</p>
</div>
);
};
// =============================================================================
// VIEW REGISTRY

View file

@ -11,6 +11,8 @@ import api from '../api';
import { clearUserDataCache } from '../utils/userCache';
import styles from './GDPR.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
type ConsentInfo = {
dataCollected?: Record<string, string>;
dataProcessing?: Record<string, string>;
@ -36,6 +38,7 @@ const downloadJson = (data: unknown, fileName: string, mimeType = 'application/j
};
export const GDPRPage: React.FC = () => {
const { t } = useLanguage();
const contactEmail = 'p.motsch@poweron.swiss';
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
@ -158,11 +161,11 @@ export const GDPRPage: React.FC = () => {
<main className={styles.content}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Your data rights</h2>
<h2 className={styles.sectionTitle}>{t('gDPR.yourDataRights')}</h2>
<div className={styles.actions}>
<div className={styles.actionCard}>
<h3>Access (Article 15)</h3>
<p>Download a full export of your account data.</p>
<h3>{t('gDPR.accessArticle15')}</h3>
<p>{t('gDPR.downloadAFullExportOf')}</p>
<button
className={styles.primaryButton}
onClick={handleDataExport}
@ -183,8 +186,8 @@ export const GDPRPage: React.FC = () => {
</div>
<div className={styles.actionCard}>
<h3>Portability (Article 20)</h3>
<p>Download a machine-readable JSON-LD export.</p>
<h3>{t('gDPR.portabilityArticle20')}</h3>
<p>{t('gDPR.downloadAMachinereadableJsonldExport')}</p>
<button
className={styles.secondaryButton}
onClick={handlePortabilityExport}
@ -205,8 +208,8 @@ export const GDPRPage: React.FC = () => {
</div>
<div className={styles.actionCard}>
<h3>Erasure (Article 17)</h3>
<p>Permanently delete your account and all associated data.</p>
<h3>{t('gDPR.erasureArticle17')}</h3>
<p>{t('gDPR.permanentlyDeleteYourAccountAnd')}</p>
{!showDeleteConfirm && (
<button
className={styles.dangerButton}
@ -275,13 +278,13 @@ export const GDPRPage: React.FC = () => {
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Processing information</h2>
{isLoadingConsent && <p className={styles.mutedText}>Loading consent info...</p>}
<h2 className={styles.sectionTitle}>{t('gDPR.processingInformation')}</h2>
{isLoadingConsent && <p className={styles.mutedText}>{t('gDPR.loadingConsentInfo')}</p>}
{consentError && <p className={styles.errorText}>{consentError}</p>}
{!isLoadingConsent && !consentError && consentInfo && (
<div className={styles.infoGrid}>
<div className={styles.infoBlock}>
<h3>Data collected</h3>
<h3>{t('gDPR.dataCollected')}</h3>
<ul>
{Object.entries(consentInfo.dataCollected || {}).map(([key, value]) => (
<li key={key}>
@ -291,7 +294,7 @@ export const GDPRPage: React.FC = () => {
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Processing</h3>
<h3>{t('gDPR.processing')}</h3>
<ul>
{Object.entries(consentInfo.dataProcessing || {}).map(([key, value]) => (
<li key={key}>
@ -301,7 +304,7 @@ export const GDPRPage: React.FC = () => {
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Your rights</h3>
<h3>{t('gDPR.yourRights')}</h3>
<ul>
{Object.entries(consentInfo.userRights || {}).map(([key, value]) => (
<li key={key}>

View file

@ -29,10 +29,14 @@ import { getUserDataCache } from '../utils/userCache';
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa';
import styles from './InvitePage.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
// Key for storing pending invitation token
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
export const InvitePage: React.FC = () => {
const { t } = useLanguage();
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { validateInvitation, acceptInvitation } = useInvitations();
@ -53,7 +57,7 @@ export const InvitePage: React.FC = () => {
useEffect(() => {
const validate = async () => {
if (!token) {
setError('Kein Einladungs-Token angegeben');
setError(t('invite.keinEinladungstokenAngegeben'));
setValidating(false);
return;
}
@ -159,7 +163,7 @@ export const InvitePage: React.FC = () => {
<div className={styles.card}>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} />
<p>Einladung wird überprüft...</p>
<p>{t('invite.einladungWirdUeberprueft')}</p>
</div>
</div>
</div>
@ -173,7 +177,7 @@ export const InvitePage: React.FC = () => {
<div className={styles.card}>
<div className={styles.errorState}>
<FaTimesCircle className={styles.errorIcon} />
<h1>Ungültige Einladung</h1>
<h1>{t('invite.ungueltigeEinladung')}</h1>
<p>{validation?.reason || 'Diese Einladung ist nicht gültig.'}</p>
<Link to="/login" className={styles.primaryButton}>
Zur Anmeldung
@ -191,9 +195,9 @@ export const InvitePage: React.FC = () => {
<div className={styles.card}>
<div className={styles.successState}>
<FaCheckCircle className={styles.successIcon} />
<h1>Erfolgreich!</h1>
<p>Sie wurden erfolgreich zum Mandanten hinzugefügt.</p>
<p className={styles.redirectMessage}>Sie werden weitergeleitet...</p>
<h1>{t('invite.erfolgreich')}</h1>
<p>{t('invite.sieWurdenErfolgreichZumMandanten')}</p>
<p className={styles.redirectMessage}>{t('invite.sieWerdenWeitergeleitet')}</p>
</div>
</div>
</div>
@ -208,12 +212,12 @@ export const InvitePage: React.FC = () => {
<div className={styles.card}>
<div className={styles.errorState}>
<FaTimesCircle className={styles.errorIcon} />
<h1>Falsche Anmeldung</h1>
<h1>{t('invite.falscheAnmeldung')}</h1>
<p>
Diese Einladung ist für <strong>{validation.targetUsername}</strong> bestimmt.
Sie sind als <strong>{cachedUser?.username || 'anderer Benutzer'}</strong> angemeldet.
</p>
<p>Bitte melden Sie sich ab und mit dem richtigen Konto wieder an.</p>
<p>{t('invite.bitteMeldenSieSichAb')}</p>
<Link to="/" className={styles.primaryButton}>
Zum Dashboard
</Link>
@ -226,9 +230,9 @@ export const InvitePage: React.FC = () => {
// Already authenticated - show accept button
const isFeatureInvite = !!validation.featureInstanceId;
const introText = isFeatureInvite
? 'Sie wurden eingeladen, einem Mandanten und einem Feature beizutreten.'
: 'Sie wurden eingeladen, einem Mandanten beizutreten.';
const rolesLabel = isFeatureInvite ? 'Features mit zugewiesenen Rollen' : 'Zugewiesene Rollen';
? t('invite.sieWurdenEingeladenEinemMandanten')
: t('invite.sieWurdenEingeladenEinemMandanten');
const rolesLabel = isFeatureInvite ? t('invite.featuresMitZugewiesenenRollen') : t('invite.zugewieseneRollen');
const rolesValue = validation.featureInstanceName && validation.roleLabels?.length
? `${validation.featureInstanceName} (${validation.roleLabels.join(', ')})`
: validation.roleLabels?.join(', ') || '';
@ -238,7 +242,7 @@ export const InvitePage: React.FC = () => {
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<h1>Einladung annehmen</h1>
<h1>{t('invite.einladungAnnehmen')}</h1>
<p>{introText}</p>
</div>
@ -251,12 +255,12 @@ export const InvitePage: React.FC = () => {
)}
{validation.mandateName && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoLabel}>{t('invite.mandant')}</span>
<span className={styles.infoValue}>{validation.mandateName}</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Status:</span>
<span className={styles.infoLabel}>{t('invite.status')}</span>
<span className={styles.infoValue}>Angemeldet</span>
</div>
{rolesValue && (
@ -301,7 +305,7 @@ export const InvitePage: React.FC = () => {
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<h1>Einladung annehmen</h1>
<h1>{t('invite.einladungAnnehmen')}</h1>
<p>{introText}</p>
</div>
@ -314,7 +318,7 @@ export const InvitePage: React.FC = () => {
)}
{validation.mandateName && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoLabel}>{t('invite.mandant')}</span>
<span className={styles.infoValue}>{validation.mandateName}</span>
</div>
)}

View file

@ -10,7 +10,10 @@ import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
function Login() {
const { t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
// Pre-fill username from invitation if provided via location.state
@ -142,7 +145,7 @@ function Login() {
{hasPendingInvitation && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
<span>Sie haben eine ausstehende Einladung. Bitte melden Sie sich an, um diese anzunehmen.</span>
<span>{t('login.sieHabenEineAusstehendeEinladung')}</span>
</div>
)}
@ -183,7 +186,7 @@ function Login() {
}}
className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`}
/>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>Passwort</label>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>{t('login.passwort')}</label>
</div>
<div className={styles.disclaimer}>
<p>
@ -234,7 +237,7 @@ function Login() {
</button>
<div className={styles.registerLink}>
<span>Du hast noch kein Konto?</span>
<span>{t('login.duHastNochKeinKonto')}</span>
</div>
<div className={styles.ctaSection}>
<button

View file

@ -5,7 +5,11 @@ import styles from './PasswordResetRequest.module.css';
import { usePasswordResetRequest } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { useLanguage } from '../providers/language/LanguageContext';
function PasswordResetRequest() {
const { t } = useLanguage();
const navigate = useNavigate();
const { requestReset, isLoading } = usePasswordResetRequest();
const [username, setUsername] = useState('');
@ -62,7 +66,7 @@ function PasswordResetRequest() {
</div>
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<h2 className={styles.title}>Passwort zurücksetzen</h2>
<h2 className={styles.title}>{t('passwordResetRequest.passwortZuruecksetzen')}</h2>
<div className={styles.loginForm}>
{validationError && (
<div className={styles.error}>{validationError}</div>
@ -97,7 +101,7 @@ function PasswordResetRequest() {
</div>
<div className={styles.infoMessage}>
<p>Geben Sie Ihren Benutzernamen ein. Falls ein Konto existiert, erhalten Sie einen Link zum Zurücksetzen des Passworts an Ihre hinterlegte E-Mail-Adresse.</p>
<p>{t('passwordResetRequest.gebenSieIhrenBenutzernamenEin')}</p>
</div>
<button
@ -111,7 +115,7 @@ function PasswordResetRequest() {
)}
<div className={styles.registerLink}>
<span>Zurück zum</span>
<span>{t('passwordResetRequest.zurueckZum')}</span>
<button
className={styles.textButton}
onClick={() => navigate("/login")}

View file

@ -7,6 +7,8 @@ import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
import { useLanguage } from '../providers/language/LanguageContext';
interface RegisterFormData {
username: string;
email: string;
@ -14,6 +16,7 @@ interface RegisterFormData {
}
function Register() {
const { t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const { register, error: registerError, isLoading } = useRegister();
@ -135,7 +138,7 @@ function Register() {
{hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
<span>Sie haben eine ausstehende Einladung. Nach der Registrierung und Anmeldung können Sie diese annehmen.</span>
<span>{t('register.sieHabenEineAusstehendeEinladung')}</span>
</div>
)}
@ -188,11 +191,11 @@ function Register() {
onBlur={() => setFullNameFocused(false)}
className={`${styles.input} ${fullNameFocused || formData.fullName ? styles.focused : ''}`}
/>
<label className={fullNameFocused || formData.fullName ? styles.focusedLabel : styles.label}>Vollständiger Name</label>
<label className={fullNameFocused || formData.fullName ? styles.focusedLabel : styles.label}>{t('register.vollstaendigerName')}</label>
</div>
<div className={styles.infoMessage}>
<p>Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.</p>
<p>{t('register.nachDerRegistrierungErhaltenSie')}</p>
</div>
<div className={styles.disclaimer}>
@ -212,7 +215,7 @@ function Register() {
)}
<div className={styles.registerLink}>
<span>Bereits registriert?</span>
<span>{t('register.bereitsRegistriert')}</span>
<button
className={styles.textButton}
onClick={() => navigate("/login", { state: location.state })}

View file

@ -5,7 +5,10 @@ import styles from './Reset.module.css';
import { usePasswordReset } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { useLanguage } from '../providers/language/LanguageContext';
function Reset() {
const { t } = useLanguage();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { resetPassword, isLoading, error } = usePasswordReset();
@ -104,7 +107,7 @@ function Reset() {
</div>
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<h2 className={styles.title}>Neues Passwort setzen</h2>
<h2 className={styles.title}>{t('reset.neuesPasswortSetzen')}</h2>
<div className={styles.loginForm}>
<div className={styles.error}>{tokenError}</div>
<div className={styles.registerLink}>
@ -116,7 +119,7 @@ function Reset() {
</button>
</div>
<div className={styles.registerLink}>
<span>oder zurück zum</span>
<span>{t('reset.oderZurueckZum')}</span>
<button
className={styles.textButton}
onClick={() => navigate("/login")}
@ -144,7 +147,7 @@ function Reset() {
</div>
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<h2 className={styles.title}>Neues Passwort setzen</h2>
<h2 className={styles.title}>{t('reset.neuesPasswortSetzen')}</h2>
<div className={styles.loginForm}>
{(validationError || error) && (
<div className={styles.error}>{validationError || error}</div>
@ -156,7 +159,7 @@ function Reset() {
{!successMessage && (
<form onSubmit={handleSubmit}>
<div className={styles.passwordHint}>Mindestens 8 Zeichen</div>
<div className={styles.passwordHint}>{t('reset.mindestens8Zeichen')}</div>
<div className={styles.floatingLabelInput}>
<input
type="password"
@ -171,7 +174,7 @@ function Reset() {
className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`}
autoComplete="new-password"
/>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>Neues Passwort</label>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>{t('reset.neuesPasswort')}</label>
</div>
@ -189,7 +192,7 @@ function Reset() {
className={`${styles.input} ${confirmPasswordFocused || confirmPassword ? styles.focused : ''}`}
autoComplete="new-password"
/>
<label className={confirmPasswordFocused || confirmPassword ? styles.focusedLabel : styles.label}>Passwort bestätigen</label>
<label className={confirmPasswordFocused || confirmPassword ? styles.focusedLabel : styles.label}>{t('reset.passwortBestaetigen')}</label>
</div>
<button
@ -203,7 +206,7 @@ function Reset() {
)}
<div className={styles.registerLink}>
<span>Zurück zum</span>
<span>{t('reset.zurueckZum')}</span>
<button
className={styles.textButton}
onClick={() => navigate("/login")}

View file

@ -19,13 +19,15 @@ import styles from './Settings.module.css';
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'profile', label: 'Profil' },
{ key: 'appearance', label: 'Darstellung' },
{ key: 'voice', label: 'Stimme & Sprache' },
{ key: 'neutralization', label: 'Neutralisierung (lokal)' },
{ key: 'privacy', label: 'Datenschutz' },
];
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
return [
{ key: 'profile', label: t('settings.tabProfil') },
{ key: 'appearance', label: t('settings.tabDarstellung') },
{ key: 'voice', label: t('settings.tabStimmeSprache') },
{ key: 'neutralization', label: t('settings.tabNeutralisierung') },
{ key: 'privacy', label: t('settings.tabDatenschutz') },
];
}
// =============================================================================
// PROFILE EDIT MODAL
@ -41,21 +43,14 @@ 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 { t, 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 languageOptions = availableLanguages.map((l) => ({ value: l.code, label: l.label || l.code }));
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: languageOptions },
{ name: 'fullName', type: 'string', label: t('settings.vollstaendigerName'), description: t('settings.ihrVollstaendigerName'), required: false, placeholder: t('settings.placeholderName') },
{ name: 'email', type: 'email', label: t('settings.emailAdresse'), description: t('settings.emailBeschreibung'), required: true, placeholder: t('settings.placeholderEmail') },
{ name: 'language', type: 'select', label: t('settings.sprache'), description: t('settings.anzeigespracheDerAnwendung'), required: true, options: languageOptions },
];
const handleSubmit = async (formData: any) => {
@ -77,12 +72,12 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2>Profil bearbeiten</h2>
<h2>{t('settings.profilBearbeiten')}</h2>
<button className={styles.closeButton} onClick={onClose}>&times;</button>
</div>
<div className={styles.modalBody}>
{error && <div className={styles.errorMessage}>{error}</div>}
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? t('settings.speichern') : t('settings.speichern')} cancelButtonText={t('settings.abbrechen')} />
</div>
</div>
</div>
@ -96,6 +91,7 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
interface VoiceMapEntry { language: string; voiceName: string; }
const VoiceSettingsTab: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
@ -181,7 +177,7 @@ const VoiceSettingsTab: React.FC = () => {
method: 'put',
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
});
setSuccess('Einstellungen gespeichert');
setSuccess(t('settings.einstellungenGespeichert'));
setTimeout(() => setSuccess(null), 3000);
await _loadSettings();
} catch (err: any) {
@ -203,7 +199,7 @@ const VoiceSettingsTab: React.FC = () => {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
audio.play();
}
} catch { setError('Stimmtest fehlgeschlagen'); }
} catch { setError(t('settings.stimmtestFehlgeschlagen')); }
finally { setTesting(null); }
}, [request]);
@ -219,7 +215,7 @@ const VoiceSettingsTab: React.FC = () => {
];
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Einstellungen werden geladen...</div>;
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('settings.einstellungenWerdenGeladen')}</div>;
return (
<>
@ -227,11 +223,11 @@ const VoiceSettingsTab: React.FC = () => {
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>STT-Sprache (Spracheingabe)</h2>
<h2 className={styles.sectionTitle}>{t('settings.sttspracheSpracheingabe')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Sprache fuer Spracherkennung</label>
<p className={styles.settingDescription}>Wird fuer die Sprache-zu-Text-Erkennung verwendet.</p>
<label className={styles.settingLabel}>{t('settings.spracheFuerSpracherkennung')}</label>
<p className={styles.settingDescription}>{t('settings.wirdFuerDieSprachezutexterkennungVerwendet')}</p>
</div>
<div className={styles.settingControl}>
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
@ -244,7 +240,7 @@ const VoiceSettingsTab: React.FC = () => {
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>TTS-Stimmen (Sprachausgabe)</h2>
<h2 className={styles.sectionTitle}>{t('settings.ttsstimmenSprachausgabe')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
</p>
@ -255,7 +251,7 @@ const VoiceSettingsTab: React.FC = () => {
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>Sprache</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>Stimme</th><th /><th /></tr></thead>
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('settings.sprache')}</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>{t('settings.stimme')}</th><th /><th /></tr></thead>
<tbody>
{voiceMap.map(entry => (
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
@ -267,7 +263,7 @@ const VoiceSettingsTab: React.FC = () => {
</button>
</td>
<td style={{ padding: '0.5rem' }}>
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>Entfernen</button>
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>{t('settings.entfernen')}</button>
</td>
</tr>
))}
@ -277,7 +273,7 @@ const VoiceSettingsTab: React.FC = () => {
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Sprache</label>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('settings.sprache')}</label>
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
@ -285,15 +281,15 @@ const VoiceSettingsTab: React.FC = () => {
</select>
</div>
<div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Stimme</label>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>{t('settings.stimme')}</label>
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
<option value="">Standard</option>
<option value="">{t('settings.standard')}</option>
{addVoices.map((v: any) => (
<option key={v.name || v} value={v.name || v}>{v.displayName || v.name || v}</option>
))}
</select>
</div>
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>Zuweisen</button>
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>{t('settings.zuweisen')}</button>
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
{testing === addLanguage ? '...' : 'Testen'}
</button>
@ -301,7 +297,7 @@ const VoiceSettingsTab: React.FC = () => {
</section>
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
{saving ? t('settings.speichern') : t('settings.einstellungenSpeichern')}
</button>
</>
);
@ -320,6 +316,7 @@ interface NeutralizationMapping {
}
const NeutralizationMappingsTab: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
@ -361,14 +358,14 @@ const NeutralizationMappingsTab: React.FC = () => {
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
};
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Mappings werden geladen...</div>;
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>{t('settings.mappingsWerdenGeladen')}</div>;
return (
<>
{error && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
<h2 className={styles.sectionTitle}>{t('settings.platzhaltermappingsLokal')}</h2>
<div
style={{
marginBottom: '1rem',
@ -382,7 +379,7 @@ const NeutralizationMappingsTab: React.FC = () => {
}}
>
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
<strong>Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung</strong> (nicht auf dieser
<strong>{t('settings.mandantAiworkspaceinstanzEinstellungenTabNeutralisierung')}</strong> (nicht auf dieser
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
</div>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
@ -439,7 +436,7 @@ const NeutralizationMappingsTab: React.FC = () => {
// =============================================================================
export const SettingsPage: React.FC = () => {
const { currentLanguage, setLanguage, availableLanguages, refreshAvailableLanguages } = useLanguage();
const { t, currentLanguage, setLanguage, availableLanguages, refreshAvailableLanguages } = useLanguage();
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser();
@ -491,12 +488,12 @@ export const SettingsPage: React.FC = () => {
return (
<div className={styles.settings}>
<header className={styles.header}>
<h1>Einstellungen</h1>
<p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
<h1>{t('settings.einstellungen')}</h1>
<p className={styles.subtitle}>{t('settings.persoenlicheEinstellungenUndPraeferenzen')}</p>
</header>
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
{_TABS.map(tab => (
{_getTabs(t).map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
@ -511,14 +508,14 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'profile' && (
<>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Konto</h2>
<h2 className={styles.sectionTitle}>{t('settings.konto')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Profil bearbeiten</label>
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
<label className={styles.settingLabel}>{t('settings.profilBearbeiten')}</label>
<p className={styles.settingDescription}>{t('settings.aendernSieIhrenNamenUnd')}</p>
</div>
<div className={styles.settingControl}>
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>{t('settings.profilOeffnen')}</button>
</div>
</div>
{currentUser && (
@ -541,34 +538,27 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'appearance' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Darstellung</h2>
<h2 className={styles.sectionTitle}>{t('settings.darstellung')}</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>{t('settings.waehlenSieZwischenHellemUnd')}</p></div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>{t('settings.themeHell')}</button>
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>{t('settings.themeDunkel')}</button>
</div>
</div>
</div>
<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.settingInfo}><label className={styles.settingLabel}>{t('settings.anzeigesprache')}</label><p className={styles.settingDescription}>{t('settings.spracheBeschreibung')}{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)} disabled={isSavingLanguage}>
{(availableLanguages.length > 0
? availableLanguages
: [
{ code: 'de', label: 'Deutsch' },
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
]
).map((l) => (
{availableLanguages.map((l) => (
<option key={l.code} value={l.code}>
{l.label || l.code}
</option>
))}
</select>
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
{isSavingLanguage && <span className={styles.savingIndicator}>{t('settings.speichern')}</span>}
</div>
</div>
</section>
@ -580,15 +570,13 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'privacy' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2>
<h2 className={styles.sectionTitle}>{t('settings.datenschutz')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Feature-Daten (z.&nbsp;B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
Löschung) finden Sie unter GDPR.
{t('settings.datenschutzBeschreibung')}
</p>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilität und Kontolöschung.</p></div>
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR öffnen</Link></div>
<div className={styles.settingInfo}><label className={styles.settingLabel}>{t('settings.gdprPrivacy')}</label><p className={styles.settingDescription}>{t('settings.datenexportPortabilitaetUndKontoloeschung')}</p></div>
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">{t('settings.gdprOeffnen')}</Link></div>
</div>
</section>
)}

View file

@ -75,6 +75,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
onActivate,
onDeactivate,
}) => {
const { t } = useLanguage();
const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode];
const activeInstances = feature.instances.filter(inst => inst.isActive);
@ -137,7 +138,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
disabled={isProcessing}
>
{isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
? (language === 'de' ? t('store.wirdAktiviert') : t('store.activating'))
: (language === 'de'
? `Aktivieren fuer ${m.label || m.name}`
: language === 'fr'
@ -151,19 +152,19 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
};
const StorePage: React.FC = () => {
const { currentLanguage } = useLanguage();
const { t, currentLanguage } = useLanguage();
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return (
<div className={styles.store}>
<div className={styles.header}>
<h1>{currentLanguage === 'de' ? 'Feature Store' : currentLanguage === 'fr' ? 'Feature Store' : 'Feature Store'}</h1>
<h1>{currentLanguage === 'de' ? 'Feature Store' : currentLanguage === 'fr' ? t('store.featureStore') : t('store.featureStore')}</h1>
<p className={styles.subtitle}>
{currentLanguage === 'de'
? 'Aktiviere Features fuer dein Konto. Deine Daten sind isoliert und nur fuer dich sichtbar.'
: currentLanguage === 'fr'
? 'Activez des fonctionnalites pour votre compte. Vos donnees sont isolees et visibles uniquement par vous.'
: 'Activate features for your account. Your data is isolated and only visible to you.'}
? t('store.activezDesFonctionnalitesPourVotre')
: t('store.activateFeaturesForYourAccount')}
</p>
</div>
@ -188,7 +189,7 @@ const StorePage: React.FC = () => {
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
{currentLanguage === 'de' ? t('store.trialEndet') : t('store.trialEnds')}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
</span>
)}
</div>
@ -198,13 +199,13 @@ const StorePage: React.FC = () => {
{loading ? (
<div className={styles.loading}>
{currentLanguage === 'de' ? 'Lade Features...' : 'Loading features...'}
{currentLanguage === 'de' ? t('store.ladeFeatures') : t('store.loadingFeatures')}
</div>
) : features.length === 0 ? (
<div className={styles.empty}>
{currentLanguage === 'de'
? 'Keine Features im Store verfuegbar.'
: 'No features available in the store.'}
? t('store.keineFeaturesImStoreVerfuegbar')
: t('store.noFeaturesAvailableInThe')}
</div>
) : (
<div className={styles.grid}>

View file

@ -23,6 +23,8 @@ import { InstanceDetailModal } from './InstanceDetailModal';
import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
import { useLanguage } from '../../providers/language/LanguageContext';
function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
@ -44,6 +46,8 @@ export interface InstanceWithStats extends FeatureInstance {
}
export const AccessManagementHub: React.FC = () => {
const { t } = useLanguage();
const {
features,
instances,
@ -339,7 +343,7 @@ export const AccessManagementHub: React.FC = () => {
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('accessManagementHub.mandantWaehlen')}</option>
{mandates.map((m) => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
@ -357,7 +361,7 @@ export const AccessManagementHub: React.FC = () => {
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">Alle</option>
<option value="">{t('accessManagementHub.alle')}</option>
{features.map((f) => (
<option key={f.code} value={f.code}>
{getFeatureLabel(f)}
@ -428,7 +432,7 @@ export const AccessManagementHub: React.FC = () => {
) : !selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('accessManagementHub.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.
</p>
@ -451,7 +455,7 @@ export const AccessManagementHub: React.FC = () => {
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.users}
</span>
<span className={hubStyles.statsLabel}>Benutzer</span>
<span className={hubStyles.statsLabel}>{t('accessManagementHub.benutzer')}</span>
</div>
</div>
<div className={hubStyles.statsCard}>
@ -460,7 +464,7 @@ export const AccessManagementHub: React.FC = () => {
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.roles}
</span>
<span className={hubStyles.statsLabel}>Rollen (max)</span>
<span className={hubStyles.statsLabel}>{t('accessManagementHub.rollenMax')}</span>
</div>
</div>
{relationshipData && relationshipData.instances.length > 0 && (
@ -493,12 +497,12 @@ export const AccessManagementHub: React.FC = () => {
{loading && filteredInstances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Instanzen...</span>
<span>{t('accessManagementHub.ladeInstanzen')}</span>
</div>
) : filteredInstances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanzen</h3>
<h3 className={styles.emptyTitle}>{t('accessManagementHub.keineFeatureinstanzen')}</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.
</p>
@ -519,7 +523,7 @@ export const AccessManagementHub: React.FC = () => {
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? 'Aktiv' : 'Inaktiv'}
{inst.enabled ? t('accessManagementHub.aktiv') : t('accessManagementHub.inaktiv')}
</span>
</div>
<div className={hubStyles.instanceMeta}>
@ -540,7 +544,7 @@ export const AccessManagementHub: React.FC = () => {
className={hubStyles.cardAction}
onClick={() => handleSyncRoles(inst)}
disabled={!inst.enabled}
title="Rollen synchronisieren"
title={t('accessManagementHub.rollenSynchronisieren')}
>
<FaCogs /> Rollen sync
</button>

View file

@ -19,7 +19,11 @@ import { DropdownSelect } from '../../components/UiComponents/DropdownSelect';
import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminFeatureAccessPage: React.FC = () => {
const { t } = useLanguage();
const {
features,
instances,
@ -84,8 +88,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Table columns
const columns = useMemo(() => [
{ key: 'label', label: 'Name', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
{ key: 'featureCode', label: 'Feature', type: 'string' as const, sortable: true, filterable: true, width: 150,
{ key: 'label', label: t('adminFeatureAccess.name'), type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
{ key: 'featureCode', label: t('adminFeatureAccess.feature'), type: 'string' as const, sortable: true, filterable: true, width: 150,
render: (value: string) => {
const feature = features.find(f => f.code === value);
if (feature) {
@ -97,8 +101,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
return value;
}
},
{ key: 'enabled', label: 'Aktiv', type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
], [features]);
{ key: 'enabled', label: t('adminFeatureAccess.aktiv'), type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
], [features, t]);
// Form attributes from backend - merge with dynamic feature options
// Exclude featureCode, config, and label since we handle them separately
@ -349,7 +353,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Feature-Instanzen für jeden Mandanten</p>
<p className={styles.pageSubtitle}>{t('adminFeatureAccess.verwaltenSieFeatureinstanzenFuerJeden')}</p>
</div>
</div>
@ -365,7 +369,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('adminFeatureAccess.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
@ -399,7 +403,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
{features.length > 0 ? (
<div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} />
<span>Verfügbare Features: </span>
<span>{t('adminFeatureAccess.verfuegbareFeatures')} </span>
{features.map((f, i) => (
<span key={f.code}>
{i > 0 && ', '}
@ -429,7 +433,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminFeatureAccess.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
</p>
@ -450,7 +454,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
actionButtons={[
{
type: 'delete' as const,
title: 'Instanz löschen',
title: t('adminFeatureAccessPage.deleteInstance'),
}
]}
customActions={[
@ -458,13 +462,13 @@ export const AdminFeatureAccessPage: React.FC = () => {
id: 'edit',
icon: <FaEdit />,
onClick: handleEditClick,
title: 'Instanz bearbeiten',
title: t('adminFeatureAccessPage.editInstance'),
},
{
id: 'syncRoles',
icon: <FaCogs />,
onClick: handleSyncRoles,
title: 'Rollen synchronisieren',
title: t('adminFeatureAccessPage.syncRoles'),
loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
}
@ -474,7 +478,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
pagination: instancesPagination,
handleDelete: handleDeleteInstance,
}}
emptyMessage="Keine Feature-Instanzen gefunden"
emptyMessage={t('adminFeatureAccess.keineFeatureinstanzenGefunden')}
/>
</div>
)}
@ -484,7 +488,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Instanz erstellen</h2>
<h2 className={styles.modalTitle}>{t('adminFeatureAccess.neueFeatureinstanzErstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -494,11 +498,11 @@ export const AdminFeatureAccessPage: React.FC = () => {
</div>
<div className={styles.modalContent}>
{features.length === 0 ? (
<p>Keine Features verfügbar. Bitte wenden Sie sich an den System-Administrator.</p>
<p>{t('adminFeatureAccess.keineFeaturesVerfuegbarBitteWenden')}</p>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminFeatureAccess.ladeFormular')}</span>
</div>
) : (
<div>
@ -525,7 +529,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
placeholder="Feature auswählen (erforderlich)"
placeholder={t('adminFeatureAccess.featureAuswaehlenErforderlich')}
className={styles.configSelect}
/>
{!createFeatureCode && (
@ -552,7 +556,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
type="text"
value={createLabel}
onChange={(value) => setCreateLabel(value)}
placeholder="Instanz-Bezeichnung eingeben..."
placeholder={t('adminFeatureAccess.instanzbezeichnungEingeben')}
className={styles.configSelect}
size="md"
required={true}
@ -591,8 +595,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
submitButtonText={t('adminFeatureAccess.erstellen')}
cancelButtonText={t('adminFeatureAccess.abbrechen')}
/>
</div>
)}
@ -608,7 +612,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Feature-Instanz bearbeiten</h2>
<h2 className={styles.modalTitle}>{t('adminFeatureAccess.featureinstanzBearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => { setShowEditModal(false); setEditingInstance(null); }}
@ -622,14 +626,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
{
name: 'label',
type: 'string' as const,
label: 'Bezeichnung',
label: t('adminFeatureAccessPage.bezeichnung'),
required: true,
editable: true,
},
{
name: 'enabled',
type: 'boolean' as const,
label: 'Aktiviert',
label: t('adminFeatureAccessPage.aktiviert'),
required: false,
editable: true,
}
@ -645,8 +649,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('adminFeatureAccess.speichern')}
cancelButtonText={t('adminFeatureAccess.abbrechen')}
/>
{/* Chatbot Configuration Section */}

View file

@ -10,13 +10,17 @@ import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, typ
import { useUserMandates } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa';
import { FaPlus, FaSync, FaBuilding, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useFeatureStore } from '../../stores/featureStore';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { t } = useLanguage();
const {
features,
instances,
@ -198,7 +202,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const columns = useMemo(() => [
{
key: 'username',
label: 'Benutzername',
label: t('adminFeatureInstanceUsers.benutzername'),
type: 'text' as const,
sortable: true,
filterable: true,
@ -207,7 +211,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
},
{
key: 'email',
label: 'E-Mail',
label: t('adminFeatureInstanceUsers.email'),
type: 'text' as const,
sortable: true,
filterable: true,
@ -216,7 +220,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
},
{
key: 'fullName',
label: 'Vollständiger Name',
label: t('adminFeatureInstanceUsers.vollstaendigerName'),
type: 'text' as const,
sortable: true,
filterable: true,
@ -225,7 +229,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
},
{
key: 'roleLabels',
label: 'Rollen',
label: t('adminFeatureInstanceUsers.rollen'),
type: 'text' as const,
sortable: false,
filterable: false,
@ -238,14 +242,14 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
},
{
key: 'enabled',
label: 'Aktiv',
label: t('adminFeatureInstanceUsers.aktiv'),
type: 'boolean' as const,
sortable: true,
filterable: true,
searchable: false,
width: 80,
},
], []);
], [t]);
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
@ -265,39 +269,39 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
return [
{
name: 'userId',
label: 'Benutzer',
label: t('adminFeatureInstanceUsersPage.benutzer'),
type: 'enum' as const,
required: true,
options: userOptions,
},
{
name: 'roleIds',
label: 'Rollen',
label: t('adminFeatureInstanceUsersPage.rollen'),
type: 'multiselect' as const,
required: true,
options: roleOptions,
}
];
}, [userOptions, roleOptions]);
}, [userOptions, roleOptions, t]);
// Form attributes for editing user roles and active flag
const editRolesFields: AttributeDefinition[] = useMemo(() => {
return [
{
name: 'roleIds',
label: 'Rollen',
label: t('adminFeatureInstanceUsersPage.rollen'),
type: 'multiselect' as const,
required: true,
options: roleOptions,
},
{
name: 'enabled',
label: 'Aktiv',
label: t('adminFeatureInstanceUsersPage.aktiv'),
type: 'checkbox' as const,
required: false,
},
];
}, [roleOptions]);
}, [roleOptions, t]);
// Handle add user submit
const handleAddUser = async (data: { userId: string; roleIds: string[] }) => {
@ -406,8 +410,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature Instanz Benutzer</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Benutzerzugriffe auf Feature-Instanzen</p>
<h1 className={styles.pageTitle}>{t('adminFeatureInstanceUsers.featureInstanzBenutzer')}</h1>
<p className={styles.pageSubtitle}>{t('adminFeatureInstanceUsers.verwaltenSieBenutzerzugriffeAufFeatureinstanzen')}</p>
</div>
</div>
@ -424,7 +428,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
onChange={(e) => setSelectedCombinedKey(e.target.value)}
disabled={loading || combinedOptions.length === 0}
>
<option value="">-- Mandant / Feature-Instanz wählen --</option>
<option value="">{t('adminFeatureInstanceUsers.mandantFeatureinstanzWaehlen')}</option>
{/* Group options by mandate */}
{(() => {
const groupedByMandate: Record<string, CombinedInstanceOption[]> = {};
@ -482,7 +486,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Roles info box */}
{selectedInstance && instanceRoles.length > 0 && (
<div className={styles.infoBox}>
<span>Verfügbare Rollen: </span>
<span>{t('adminFeatureInstanceUsers.verfuegbareRollen')} </span>
{instanceRoles.map((r, i) => (
<span key={r.id}>
{i > 0 && ', '}
@ -496,7 +500,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
<div className={styles.warningBox || styles.infoBox}>
<span> </span>
<span>Diese Instanz hat noch keine Rollen. Bitte synchronisieren Sie die Rollen zuerst unter "Feature-Instanzen".</span>
<span>{t('adminFeatureInstanceUsers.dieseInstanzHatNochKeine')}</span>
</div>
)}
@ -504,11 +508,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{!selectedCombinedKey ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanz ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminFeatureInstanceUsers.keineFeatureinstanzAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
{combinedOptions.length === 0
? 'Es gibt noch keine Feature-Instanzen. Erstellen Sie zuerst Feature-Instanzen unter "Feature-Instanzen".'
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
? t('adminFeatureInstanceUsers.esGibtNochKeineFeatureinstanzen')
: t('adminFeatureInstanceUsers.waehlenSieEineFeatureinstanzAus')}
</p>
</div>
) : (
@ -528,11 +532,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rollen bearbeiten',
title: t('adminFeatureInstanceUsersPage.editRoles'),
},
{
type: 'delete' as const,
title: 'Aus Instanz entfernen',
title: t('adminFeatureInstanceUsersPage.removeFromInstance'),
}
]}
onDelete={handleRemoveUser}
@ -549,7 +553,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
return false;
},
}}
emptyMessage="Keine Benutzer gefunden"
emptyMessage={t('adminFeatureInstanceUsers.keineBenutzerGefunden')}
/>
</div>
)}
@ -559,7 +563,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer zur Feature-Instanz hinzufügen</h2>
<h2 className={styles.modalTitle}>{t('adminFeatureInstanceUsers.benutzerZurFeatureinstanzHinzufuegen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowAddModal(false)}
@ -569,17 +573,17 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Benutzer haben bereits Zugriff auf diese Feature-Instanz.</p>
<p>{t('adminFeatureInstanceUsers.alleBenutzerHabenBereitsZugriff')}</p>
) : instanceRoles.length === 0 ? (
<p>Diese Feature-Instanz hat keine Rollen. Bitte synchronisieren Sie zuerst die Rollen.</p>
<p>{t('adminFeatureInstanceUsers.dieseFeatureinstanzHatKeineRollen')}</p>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
cancelButtonText="Abbrechen"
submitButtonText={t('adminFeatureInstanceUsers.hinzufuegen')}
cancelButtonText={t('adminFeatureInstanceUsers.abbrechen')}
/>
)}
</div>
@ -607,8 +611,8 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('adminFeatureInstanceUsers.speichern')}
cancelButtonText={t('adminFeatureInstanceUsers.abbrechen')}
/>
</div>
</div>

View file

@ -19,6 +19,8 @@ import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface Feature {
id?: string;
code: string; // Backend uses 'code' not 'featureCode'
@ -40,6 +42,8 @@ interface FeatureRole {
}
export const AdminFeatureRolesPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
// State
const [features, setFeatures] = useState<Feature[]>([]);
@ -66,7 +70,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
}
} catch (err: any) {
console.error('Error loading features:', err);
setError('Fehler beim Laden der Features');
setError(t('adminFeatureRoles.fehlerBeimLadenDerFeatures'));
}
};
loadFeatures();
@ -108,7 +112,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
}
} catch (err: any) {
console.error('Error loading feature roles:', err);
setError('Fehler beim Laden der Feature-Rollen');
setError(t('adminFeatureRoles.fehlerBeimLadenDerFeaturerollen'));
setRoles([]);
setPagination(null);
} finally {
@ -131,7 +135,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
const columns = useMemo(() => [
{
key: 'roleLabel',
label: 'Rollen-Label',
label: t('adminFeatureRoles.rollenLabel'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -140,7 +144,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
},
{
key: 'description',
label: 'Beschreibung',
label: t('adminFeatureRoles.beschreibung'),
type: 'string' as const,
sortable: false,
width: 300,
@ -148,7 +152,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
},
{
key: 'featureCode',
label: 'Feature',
label: t('adminFeatureRoles.feature'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -159,49 +163,49 @@ export const AdminFeatureRolesPage: React.FC = () => {
</span>
)
},
], []);
], [t]);
// Form attributes for create
const createFields: AttributeDefinition[] = useMemo(() => {
const fields: AttributeDefinition[] = [
{
name: 'roleLabel',
label: 'Rollen-Label',
label: t('adminFeatureRolesPage.rollenLabel'),
type: 'string',
required: true,
description: 'Eindeutiger Bezeichner der Rolle (z.B. trustee-admin)'
description: t('adminFeatureRolesPage.rollenLabelBeschreibung')
},
{
name: 'description',
label: 'Beschreibung',
label: t('adminFeatureRolesPage.beschreibung'),
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
description: t('adminFeatureRolesPage.mehrsprachigeBeschreibung')
}
];
return fields;
}, []);
}, [t]);
// Form attributes for edit
const editFields: AttributeDefinition[] = useMemo(() => {
return [
{
name: 'roleLabel',
label: 'Rollen-Label',
label: t('adminFeatureRolesPage.rollenLabel'),
type: 'string',
required: true,
readonly: true, // Label should not be changed after creation
description: 'Eindeutiger Bezeichner der Rolle'
readonly: true,
description: t('adminFeatureRolesPage.rollenLabelReadonly')
},
{
name: 'description',
label: 'Beschreibung',
label: t('adminFeatureRolesPage.beschreibung'),
type: 'multilingual',
required: false,
description: 'Mehrsprachige Beschreibung der Rolle'
description: t('adminFeatureRolesPage.mehrsprachigeBeschreibung')
}
];
}, []);
}, [t]);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
@ -280,8 +284,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature Rollen & Rechte</h1>
<p className={styles.pageSubtitle}>Template-Rollen und deren Berechtigungen für Feature-Instanzen verwalten</p>
<h1 className={styles.pageTitle}>{t('adminFeatureRoles.featureRollenRechte')}</h1>
<p className={styles.pageSubtitle}>{t('adminFeatureRoles.templaterollenUndDerenBerechtigungenFuer')}</p>
</div>
</div>
@ -297,7 +301,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">-- Feature wählen --</option>
<option value="">{t('adminFeatureRoles.featureWaehlen')}</option>
{features.map(f => {
const featureCode = f.code || f.featureCode || '';
return (
@ -343,7 +347,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
{!selectedFeatureCode ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Feature ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminFeatureRoles.keinFeatureAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
</p>
@ -365,11 +369,11 @@ export const AdminFeatureRolesPage: React.FC = () => {
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rolle bearbeiten',
title: t('adminFeatureRolesPage.editRole'),
},
{
type: 'delete' as const,
title: 'Rolle löschen',
title: t('adminFeatureRolesPage.deleteRole'),
}
]}
customActions={[
@ -377,7 +381,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
id: 'permissions',
icon: <FaShieldAlt />,
onClick: (role: FeatureRole) => setPermissionsRole(role),
title: 'Berechtigungen verwalten',
title: t('adminFeatureRolesPage.managePermissions'),
}
]}
onDelete={handleDeleteRole}
@ -386,7 +390,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
pagination,
handleDelete: handleDeleteRole,
}}
emptyMessage="Keine Feature-Rollen gefunden"
emptyMessage={t('adminFeatureRoles.keineFeaturerollenGefunden')}
/>
</div>
)}
@ -396,7 +400,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Rolle erstellen</h2>
<h2 className={styles.modalTitle}>{t('adminFeatureRoles.neueFeaturerolleErstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -415,7 +419,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
cancelButtonText="Abbrechen"
cancelButtonText={t('adminFeatureRoles.abbrechen')}
/>
</div>
</div>
@ -427,7 +431,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Feature-Rolle bearbeiten</h2>
<h2 className={styles.modalTitle}>{t('adminFeatureRoles.featurerolleBearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
@ -446,8 +450,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
submitButtonText={isSubmitting ? t('adminFeatureRoles.speichern') : t('adminFeatureRoles.speichern')}
cancelButtonText={t('adminFeatureRoles.abbrechen')}
/>
</div>
</div>
@ -474,7 +478,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{permissionsRole.featureCode}</strong></span>
<span style={{ marginLeft: '1rem' }}>Template-Rolle (global)</span>
<span style={{ marginLeft: '1rem' }}>{t('adminFeatureRoles.templaterolleGlobal')}</span>
</div>
<AccessRulesEditor
roleId={permissionsRole.id}

View file

@ -10,12 +10,16 @@ import { useInvitations, type Invitation, type InvitationCreate } from '../../ho
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminInvitationsPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const {
invitations,
@ -83,7 +87,7 @@ export const AdminInvitationsPage: React.FC = () => {
const columns = useMemo(() => [
{
key: 'targetUsername',
label: 'Benutzername',
label: t('adminInvitations.benutzername'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -92,7 +96,7 @@ export const AdminInvitationsPage: React.FC = () => {
},
{
key: 'email',
label: 'E-Mail',
label: t('adminInvitations.email'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -101,7 +105,7 @@ export const AdminInvitationsPage: React.FC = () => {
const emailText = value || '-';
const emailSent = (row as any).emailSent;
return (
<span title={emailSent ? 'Email wurde gesendet' : 'Email nicht gesendet'}>
<span title={emailSent ? t('adminInvitations.emailWurdeGesendet') : t('adminInvitations.emailNichtGesendet')}>
{emailText} {emailSent && '✓'}
</span>
);
@ -109,7 +113,7 @@ export const AdminInvitationsPage: React.FC = () => {
},
{
key: 'roleIds',
label: 'Rollen',
label: t('adminInvitations.rollen'),
type: 'string', // Array rendered as string
sortable: false,
filterable: false,
@ -124,7 +128,7 @@ export const AdminInvitationsPage: React.FC = () => {
} as any,
{
key: 'expiresAt',
label: 'Gültig bis',
label: t('adminInvitations.gueltigBis'),
type: 'number' as const,
sortable: true,
width: 150,
@ -140,7 +144,7 @@ export const AdminInvitationsPage: React.FC = () => {
},
{
key: 'currentUses',
label: 'Verwendet',
label: t('adminInvitations.verwendet'),
type: 'string' as const,
sortable: true,
width: 100,
@ -148,13 +152,13 @@ export const AdminInvitationsPage: React.FC = () => {
},
{
key: 'createdAt',
label: 'Erstellt',
label: t('adminInvitations.erstellt'),
type: 'number' as const,
sortable: true,
width: 150,
render: (value: number) => formatDate(value)
},
], [roles]);
], [roles, t]);
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
const createFields: AttributeDefinition[] = useMemo(() => {
@ -174,7 +178,7 @@ export const AdminInvitationsPage: React.FC = () => {
// Add helper field expiresInHours if not in model but fields exist
if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) {
fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number',
fields.push({ name: 'expiresInHours', label: t('adminInvitationsPage.gueltigkeitsdauerStunden'), type: 'number',
required: true, default: 72 } as any);
}
// Override required for targetUsername and email (both required for invitations)
@ -258,8 +262,8 @@ export const AdminInvitationsPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Einladungen</h1>
<p className={styles.pageSubtitle}>Erstellen und verwalten Sie Einladungen für neue Benutzer</p>
<h1 className={styles.pageTitle}>{t('adminInvitations.einladungen')}</h1>
<p className={styles.pageSubtitle}>{t('adminInvitations.erstellenUndVerwaltenSieEinladungen')}</p>
</div>
</div>
@ -275,7 +279,7 @@ export const AdminInvitationsPage: React.FC = () => {
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('adminInvitations.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
@ -326,7 +330,7 @@ export const AdminInvitationsPage: React.FC = () => {
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminInvitations.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
</p>
@ -347,7 +351,7 @@ export const AdminInvitationsPage: React.FC = () => {
actionButtons={[
{
type: 'delete' as const,
title: 'Einladung widerrufen',
title: t('adminInvitationsPage.revokeInvitation'),
}
]}
customActions={[
@ -355,7 +359,7 @@ export const AdminInvitationsPage: React.FC = () => {
id: 'showUrl',
icon: <FaLink />,
onClick: handleShowUrl,
title: 'Einladungs-Link anzeigen',
title: t('adminInvitationsPage.showInvitationLink'),
}
]}
hookData={{
@ -363,7 +367,7 @@ export const AdminInvitationsPage: React.FC = () => {
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
pagination,
}}
emptyMessage="Keine Einladungen gefunden"
emptyMessage={t('adminInvitations.keineEinladungenGefunden')}
/>
</div>
)}
@ -373,7 +377,7 @@ export const AdminInvitationsPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Einladung erstellen</h2>
<h2 className={styles.modalTitle}>{t('adminInvitations.neueEinladungErstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -385,12 +389,12 @@ export const AdminInvitationsPage: React.FC = () => {
{roles.filter(r => !r.featureInstanceId).length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
<span>{t('adminInvitations.ladeRollen')}</span>
</div>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminInvitations.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -398,8 +402,8 @@ export const AdminInvitationsPage: React.FC = () => {
mode="create"
onSubmit={handleCreateInvitation}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Einladung erstellen"
cancelButtonText="Abbrechen"
submitButtonText={t('adminInvitations.einladungErstellen')}
cancelButtonText={t('adminInvitations.abbrechen')}
/>
)}
</div>
@ -435,7 +439,7 @@ export const AdminInvitationsPage: React.FC = () => {
<button
className={styles.copyButton}
onClick={() => handleCopyUrl(showUrlModal.inviteUrl)}
title="In Zwischenablage kopieren"
title={t('adminInvitations.inZwischenablageKopieren')}
>
<FaCopy />
{copySuccess ? ' Kopiert!' : ' Kopieren'}

View file

@ -23,14 +23,24 @@ type ProgressInfo = {
total: number;
error?: string;
done?: boolean;
/** Keys in language set before sync (from sync-diff). */
keysCurrent?: number;
/** New keys vs xx before PUT (from sync-diff). */
keysPending?: number;
/** Master (xx) key count (from sync-diff). */
keysMasterTotal?: number;
/** Keys AI-translated in last PUT (optional). */
keysTranslated?: number;
};
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: 200 },
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 120 },
{ key: 'entriesCount', label: 'Einträge', type: 'number', sortable: true, width: 100 },
];
function _getColumns(t: (key: string) => string): ColumnConfig[] {
return [
{ key: 'id', label: t('adminLanguages.code'), type: 'text', sortable: true, filterable: true, width: 90 },
{ key: 'label', label: t('adminLanguages.bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
{ key: 'status', label: t('adminLanguages.status'), type: 'text', sortable: true, filterable: true, width: 120 },
{ key: 'entriesCount', label: t('adminLanguages.eintraege'), type: 'number', sortable: true, width: 100 },
];
}
const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
@ -86,7 +96,28 @@ const _isoChoices: { value: string; label: string }[] = [
// ---------------------------------------------------------------------------
const _ProgressOverlay: React.FC<{ progress: ProgressInfo }> = ({ progress }) => {
const { t } = useLanguage();
const pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
const master = progress.keysMasterTotal;
let keysLine: string | null = null;
if (master != null && master > 0) {
const pending = progress.keysPending ?? 0;
if (pending > 0 && progress.keysTranslated !== undefined) {
keysLine = t('{tr} / {pending} neue Schlüssel übersetzt · Basis {m}', {
tr: String(progress.keysTranslated),
pending: String(pending),
m: String(master),
});
} else if (pending > 0) {
keysLine = t('{pending} / {m} (neu / Basis-Schlüssel)', {
pending: String(pending),
m: String(master),
});
} else {
const cur = progress.keysCurrent ?? 0;
keysLine = t('{cur} / {m} Schlüssel abgedeckt', { cur: String(cur), m: String(master) });
}
}
return (
<div
style={{
@ -138,10 +169,18 @@ const _ProgressOverlay: React.FC<{ progress: ProgressInfo }> = ({ progress }) =>
}}
/>
</div>
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
{progress.current} / {progress.total}
{progress.done && !progress.error && ' — fertig'}
</p>
{progress.total > 1 && (
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
{t('Schritt {cur} von {tot}', { cur: String(progress.current), tot: String(progress.total) })}
{progress.done && !progress.error && `${t('fertig')}`}
</p>
)}
{progress.total === 1 && progress.done && !progress.error && (
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>{t('fertig')}</p>
)}
{keysLine && (
<p style={{ fontSize: '0.8rem', opacity: 0.75, marginTop: '0.35rem' }}>{keysLine}</p>
)}
{progress.error && (
<p style={{ color: 'var(--error-color, #c53030)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
{progress.error}
@ -269,10 +308,39 @@ export const AdminLanguagesPage: React.FC = () => {
busyRef.current = true;
setError(null);
const label = rows.find((r) => r.id === code)?.label || code;
setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), current: 0, total: 1 });
let keysCurrent: number | undefined;
let keysPending: number | undefined;
let keysMasterTotal: number | undefined;
try {
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
setProgress({ message: t('{lang} aktualisiert.', { lang: label }), current: 1, total: 1, done: true });
const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* sync-diff optional */
}
setProgress({
message: t('Aktualisiere {lang}…', { lang: label }),
current: 0,
total: 1,
keysCurrent,
keysPending,
keysMasterTotal,
});
try {
const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
const d = putRes.data || {};
const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0);
setProgress({
message: t('{lang} aktualisiert.', { lang: label }),
current: 1,
total: 1,
done: true,
keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent,
keysPending: pendingAfterPut,
keysMasterTotal,
keysTranslated: typeof d.translated === 'number' ? d.translated : undefined,
});
await _load();
await refreshAvailableLanguages();
await reloadLanguage();
@ -318,13 +386,38 @@ export const AdminLanguagesPage: React.FC = () => {
const errors: string[] = [];
for (const code of langCodes) {
const label = rows.find((r) => r.id === code)?.label || code;
let keysCurrent: number | undefined;
let keysPending: number | undefined;
let keysMasterTotal: number | undefined;
try {
const dr = await api.get(`/api/i18n/sets/${encodeURIComponent(code)}/sync-diff`);
keysCurrent = dr.data?.currentEntryCount;
keysPending = dr.data?.addedCount;
keysMasterTotal = dr.data?.masterEntryCount;
} catch {
/* sync-diff optional */
}
setProgress({
message: t('Aktualisiere {lang}… ({n}/{total})', { lang: label, n: String(step + 1), total: String(totalSteps) }),
current: step,
message: t('Aktualisiere {lang}…', { lang: label }),
current: step + 1,
total: totalSteps,
keysCurrent,
keysPending,
keysMasterTotal,
});
try {
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
const putRes = await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
const d = putRes.data || {};
const pendingAfterPut = Array.isArray(d.added) ? d.added.length : (keysPending ?? 0);
setProgress({
message: t('Aktualisiere {lang}…', { lang: label }),
current: step + 1,
total: totalSteps,
keysCurrent: typeof d.entriesCount === 'number' ? d.entriesCount : keysCurrent,
keysPending: pendingAfterPut,
keysMasterTotal,
keysTranslated: typeof d.translated === 'number' ? d.translated : undefined,
});
} catch (e: any) {
errors.push(`${code}: ${e.response?.data?.detail || e.message}`);
}
@ -530,7 +623,7 @@ export const AdminLanguagesPage: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={rows}
columns={_columns}
columns={_getColumns(t)}
loading={loading}
pagination={false}
selectable={false}

View file

@ -12,6 +12,8 @@ import api from '../../api';
import styles from './Admin.module.css';
import logStyles from './AdminLogsPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
const LOG_LEVEL_COLORS: Record<string, string> = {
ERROR: 'var(--log-error, #e53e3e)',
WARNING: 'var(--log-warning, #d69e2e)',
@ -27,6 +29,8 @@ function _parseLogLevel(line: string): string | null {
}
export const AdminLogsPage: React.FC = () => {
const { t } = useLanguage();
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -104,7 +108,7 @@ export const AdminLogsPage: React.FC = () => {
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Gateway Logs</h1>
<h1 className={styles.pageTitle}>{t('adminLogs.gatewayLogs')}</h1>
<p className={styles.pageSubtitle}>
{lines.length > 0
? `${lines.length} Einträge`
@ -117,7 +121,7 @@ export const AdminLogsPage: React.FC = () => {
className={styles.secondaryButton}
onClick={_handleDownload}
disabled={lines.length === 0}
title="Log herunterladen"
title={t('adminLogs.logHerunterladen')}
>
<FaDownload /> Download
</button>
@ -136,7 +140,7 @@ export const AdminLogsPage: React.FC = () => {
min={1}
max={50000}
/>
<label className={logStyles.controlLabel}>Einträge</label>
<label className={logStyles.controlLabel}>{t('adminLogs.eintraege')}</label>
<button
className={styles.primaryButton}
onClick={_handleLoad}
@ -179,7 +183,7 @@ export const AdminLogsPage: React.FC = () => {
{lines.length === 0 && !loading && (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>📋</div>
<p className={styles.emptyTitle}>Keine Logs geladen</p>
<p className={styles.emptyTitle}>{t('adminLogs.keineLogsGeladen')}</p>
<p className={styles.emptyDescription}>
Gib die gewünschte Anzahl Einträge ein und klicke auf "Laden".
</p>
@ -188,7 +192,7 @@ export const AdminLogsPage: React.FC = () => {
{loading && lines.length === 0 && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Logs werden geladen...</span>
<span>{t('adminLogs.logsWerdenGeladen')}</span>
</div>
)}
{lines.map((line, idx) => {

View file

@ -35,6 +35,8 @@ import {
} from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// Types for cleanup result
interface DuplicateGroup {
roleId: string;
@ -73,6 +75,8 @@ interface TemplateFixResult {
}
export const AdminMandateRolePermissionsPage: React.FC = () => {
const { t } = useLanguage();
const {
roles,
loading,
@ -212,10 +216,10 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'mandate', label: 'Mandanten-Rollen' },
{ value: 'all', label: 'Alle (inkl. Templates)' },
{ value: 'global', label: 'Nur Templates' },
], []);
{ value: 'mandate', label: t('adminMandateRolePermissionsPage.mandantenRollen') },
{ value: 'all', label: t('adminMandateRolePermissionsPage.alleInklTemplates') },
{ value: 'global', label: t('adminMandateRolePermissionsPage.nurTemplates') },
], [t]);
if (error) {
return (
@ -249,7 +253,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title="Doppelte Regeln finden und bereinigen"
title={t('adminMandateRolePermissions.doppelteRegelnFindenUndBereinigen')}
>
<FaBroom /> Duplikate bereinigen
</button>
@ -266,7 +270,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{/* Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Mandant:</label>
<label className={styles.filterLabel}>{t('adminMandateRolePermissions.mandant')}</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
@ -310,7 +314,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{loading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
<span>{t('adminMandateRolePermissions.ladeRollen')}</span>
</div>
)}
@ -318,13 +322,13 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{!loading && roles.length === 0 && (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>Keine Rollen gefunden</p>
<p>{t('adminMandateRolePermissions.keineRollenGefunden')}</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
: scopeFilter === 'global'
? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
? t('adminMandateRolePermissions.esGibtNochKeineRollentemplates')
: t('adminMandateRolePermissions.esGibtNochKeineRollen')}
</p>
</div>
)}
@ -399,7 +403,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{cleanupLoading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{cleanupPhase === 'idle' ? 'Analysiere Duplikate...' : 'Bereinige Duplikate...'}</span>
<span>{cleanupPhase === 'idle' ? t('adminMandateRolePermissions.analysiereDuplikate') : t('adminMandateRolePermissions.bereinigeDuplikate')}</span>
</div>
)}
@ -418,11 +422,11 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Regeln total</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminMandateRolePermissions.regelnTotal')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Eindeutige Regeln</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminMandateRolePermissions.eindeutigeRegeln')}</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
@ -442,14 +446,14 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{cleanupPhase === 'done' && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span><strong>{cleanupResult.deletedCount}</strong> doppelte Regeln wurden erfolgreich entfernt.</span>
<span><strong>{cleanupResult.deletedCount}</strong> {t('adminMandateRolePermissions.doppelteRegelnWurdenErfolgreichEntfernt')}</span>
</div>
)}
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>Keine Duplikate gefunden. Alles sauber!</span>
<span>{t('adminMandateRolePermissions.keineDuplikateGefundenAllesSauber')}</span>
</div>
)}
@ -501,11 +505,11 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{templateFixResult.totalUserMandateRoles}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Rollen-Zuweisungen total</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminMandateRolePermissions.rollenzuweisungenTotal')}</div>
</div>
<div style={{ padding: '0.75rem', background: templateFixResult.invalidAssignments > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.invalidAssignments > 0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Template statt Instanz</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminMandateRolePermissions.templateStattInstanz')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.fixedCount > 0 ? '#2f855a' : 'var(--text-primary)' }}>
@ -522,8 +526,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Rolle</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Mandant</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('adminMandateRolePermissions.rolle')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('adminMandateRolePermissions.mandant')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Aktion</th>
</tr>
</thead>
@ -563,7 +567,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
{templateFixResult && templateFixResult.invalidAssignments === 0 && (
<div style={{ marginTop: '1rem', padding: '0.5rem 0.75rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>Keine fehlerhaften Template-Rollen-Zuweisungen.</span>
<span>{t('adminMandateRolePermissions.keineFehlerhaftenTemplaterollenzuweisungen')}</span>
</div>
)}
</>
@ -572,7 +576,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'}
{cleanupPhase === 'done' ? t('adminMandateRolePermissions.schliessen') : t('adminMandateRolePermissions.abbrechen')}
</button>
{cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
<button

View file

@ -24,7 +24,11 @@ import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminMandateRolesPage: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { showError, showWarning } = useToast();
const {
@ -99,7 +103,7 @@ export const AdminMandateRolesPage: React.FC = () => {
const columns = useMemo(() => [
{
key: 'roleLabel',
label: 'Bezeichnung',
label: t('adminMandateRoles.bezeichnung'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -108,7 +112,7 @@ export const AdminMandateRolesPage: React.FC = () => {
},
{
key: 'description',
label: 'Beschreibung',
label: t('adminMandateRoles.beschreibung'),
type: 'string' as const,
sortable: false,
filterable: false,
@ -117,7 +121,7 @@ export const AdminMandateRolesPage: React.FC = () => {
},
{
key: 'scopeType',
label: 'Geltungsbereich',
label: t('adminMandateRoles.geltungsbereich'),
type: 'string' as const,
sortable: true,
filterable: true,
@ -144,7 +148,7 @@ export const AdminMandateRolesPage: React.FC = () => {
);
}
},
], []);
], [t]);
// Form attributes from backend - for create form
const createFields: AttributeDefinition[] = useMemo(() => {
@ -158,13 +162,13 @@ export const AdminMandateRolesPage: React.FC = () => {
if (fields.length > 0) {
fields.push({
name: 'scope',
label: 'Geltungsbereich',
label: t('adminMandateRolesPage.geltungsbereich'),
type: 'enum' as any,
required: true,
default: 'mandate',
options: [
{ value: 'mandate', label: 'Nur dieser Mandant' },
{ value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' },
{ value: 'mandate', label: t('adminMandateRolesPage.nurDieserMandant') },
{ value: 'global', label: t('adminMandateRolesPage.templateBeiNeuenMandanten') },
]
});
}
@ -310,8 +314,8 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Rollen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie System-, globale und mandantenspezifische Rollen</p>
<h1 className={styles.pageTitle}>{t('adminMandateRoles.rollen')}</h1>
<p className={styles.pageSubtitle}>{t('adminMandateRoles.verwaltenSieSystemGlobaleUnd')}</p>
</div>
<div className={styles.headerActions}>
<button
@ -343,7 +347,7 @@ export const AdminMandateRolesPage: React.FC = () => {
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('adminMandateRoles.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
@ -353,7 +357,7 @@ export const AdminMandateRolesPage: React.FC = () => {
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Filter:</label>
<label className={styles.filterLabel}>{t('adminMandateRoles.filter')}</label>
<select
className={styles.filterSelect}
value={scopeFilter}
@ -361,8 +365,8 @@ export const AdminMandateRolesPage: React.FC = () => {
style={{ minWidth: 150 }}
>
<option value="mandate">Mandanten-Rollen</option>
<option value="all">Alle (inkl. Templates)</option>
<option value="global">Nur Templates</option>
<option value="all">{t('adminMandateRoles.alleInklTemplates')}</option>
<option value="global">{t('adminMandateRoles.nurTemplates')}</option>
</select>
</div>
@ -401,7 +405,7 @@ export const AdminMandateRolesPage: React.FC = () => {
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminMandateRoles.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
</p>
@ -423,12 +427,12 @@ export const AdminMandateRolesPage: React.FC = () => {
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rolle bearbeiten',
title: t('adminMandateRolesPage.editRole'),
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: 'System-Rollen können nicht bearbeitet werden' } : false
},
{
type: 'delete' as const,
title: 'Rolle löschen',
title: t('adminMandateRolesPage.deleteRole'),
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: 'System-Rollen können nicht gelöscht werden' } : false
}
]}
@ -438,7 +442,7 @@ export const AdminMandateRolesPage: React.FC = () => {
pagination: pagination,
handleDelete: handleDeleteRole,
}}
emptyMessage="Keine Rollen gefunden"
emptyMessage={t('adminMandateRoles.keineRollenGefunden')}
/>
</div>
)}
@ -448,7 +452,7 @@ export const AdminMandateRolesPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Rolle erstellen</h2>
<h2 className={styles.modalTitle}>{t('adminMandateRoles.neueRolleErstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -460,7 +464,7 @@ export const AdminMandateRolesPage: React.FC = () => {
{createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminMandateRoles.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -469,7 +473,7 @@ export const AdminMandateRolesPage: React.FC = () => {
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
cancelButtonText="Abbrechen"
cancelButtonText={t('adminMandateRoles.abbrechen')}
/>
)}
</div>
@ -494,7 +498,7 @@ export const AdminMandateRolesPage: React.FC = () => {
{editFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminMandateRoles.ladeFormular')}</span>
</div>
) : (
<>
@ -511,8 +515,8 @@ export const AdminMandateRolesPage: React.FC = () => {
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
submitButtonText={isSubmitting ? t('adminMandateRoles.speichern') : t('adminMandateRoles.speichern')}
cancelButtonText={t('adminMandateRoles.abbrechen')}
/>
</>
)}

View file

@ -17,10 +17,14 @@ import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminMandatesPage: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
@ -120,7 +124,7 @@ export const AdminMandatesPage: React.FC = () => {
}
const entered = await prompt(
`Um den Mandanten "${mandate.name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:`,
{ title: 'Mandant deaktivieren', confirmLabel: 'Deaktivieren', variant: 'danger', placeholder: mandate.name },
{ title: t('adminMandatesPage.deactivateMandate'), confirmLabel: t('adminMandatesPage.deactivate'), variant: 'danger', placeholder: mandate.name },
);
if (entered === null) return;
if (entered !== mandate.name) {
@ -137,7 +141,7 @@ export const AdminMandatesPage: React.FC = () => {
}
const entered = await prompt(
`ACHTUNG: Dies löscht den Mandanten "${mandate.name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:`,
{ title: 'Hard Delete (irreversibel)', confirmLabel: 'Endgültig löschen', variant: 'danger', placeholder: mandate.name },
{ title: t('adminMandatesPage.hardDeleteIrreversible'), confirmLabel: t('adminMandatesPage.deletePermanently'), variant: 'danger', placeholder: mandate.name },
);
if (entered === null) return;
if (entered !== mandate.name) {
@ -169,7 +173,7 @@ export const AdminMandatesPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Mandanten</h1>
<p className={styles.pageSubtitle}>Verwalten Sie alle Mandanten im System</p>
<p className={styles.pageSubtitle}>{t('adminMandates.verwaltenSieAlleMandantenIm')}</p>
</div>
<div className={styles.headerActions}>
<button
@ -213,11 +217,11 @@ export const AdminMandatesPage: React.FC = () => {
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
title: t('adminMandatesPage.edit'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Deaktivieren (Soft-Delete)',
title: t('adminMandatesPage.deactivateSoftDelete'),
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
@ -227,7 +231,7 @@ export const AdminMandatesPage: React.FC = () => {
id: 'hard-delete',
icon: <FaSkullCrossbones />,
onClick: handleHardDeleteMandate,
title: 'Hard Delete (irreversibel)',
title: t('adminMandatesPage.hardDeleteIrreversible'),
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false,
@ -241,7 +245,7 @@ export const AdminMandatesPage: React.FC = () => {
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Mandanten gefunden"
emptyMessage={t('adminMandates.keineMandantenGefunden')}
/>
</div>
@ -250,7 +254,7 @@ export const AdminMandatesPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neuer Mandant</h2>
<h2 className={styles.modalTitle}>{t('adminMandates.neuerMandant')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -266,7 +270,7 @@ export const AdminMandatesPage: React.FC = () => {
{mandateAttrsLoading || createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminMandates.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -274,8 +278,8 @@ export const AdminMandatesPage: React.FC = () => {
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
submitButtonText={t('adminMandates.erstellen')}
cancelButtonText={t('adminMandates.abbrechen')}
/>
)}
</div>
@ -296,7 +300,7 @@ export const AdminMandatesPage: React.FC = () => {
>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Mandant bearbeiten</h2>
<h2 className={styles.modalTitle}>{t('adminMandates.mandantBearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => {
@ -327,7 +331,7 @@ export const AdminMandatesPage: React.FC = () => {
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminMandates.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -339,8 +343,8 @@ export const AdminMandatesPage: React.FC = () => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('adminMandates.speichern')}
cancelButtonText={t('adminMandates.abbrechen')}
/>
)}
</div>

View file

@ -10,6 +10,8 @@ import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevr
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface UserOption {
id: string;
username: string;
@ -84,6 +86,8 @@ interface UserAccessOverview {
type TabId = 'overview' | 'ui' | 'data' | 'resources';
export const AdminUserAccessOverviewPage: React.FC = () => {
const { t } = useLanguage();
const [users, setUsers] = useState<UserOption[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [overview, setOverview] = useState<UserAccessOverview | null>(null);
@ -201,10 +205,10 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
</div>
)}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugriff nach Mandant</h3>
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>{t('adminUserAccessOverview.zugriffNachMandant')}</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineMandatezuordnungenVorhanden')}</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map((mandate) => {
@ -228,7 +232,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandateRoles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen direkt am Mandanten.</p>
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineRollenDirektAmMandanten')}</p>
) : (
<ul className={styles.accessOverviewRoleBullets}>
{mandateRoles.map((r) => (
@ -253,7 +257,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<div className={styles.accessOverviewSubheading}>Feature-Instanzen</div>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineFeatureinstanzenZugewiesen')}</p>
) : (
<div className={styles.accessOverviewInstanceStack}>
{mandate.featureInstances.map((instance) => {
@ -334,7 +338,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<div className={styles.roleContent}>
<div style={{ fontSize: '0.875rem' }}>
<p>
<strong>Beschreibung:</strong> {_roleDescriptionLine(role) || '—'}
<strong>{t('adminUserAccessOverview.beschreibung')}</strong> {_roleDescriptionLine(role) || '—'}
</p>
</div>
</div>
@ -355,13 +359,13 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>UI-Zugriffsrechte bestimmen, welche Seiten und Views der Benutzer sehen kann.</span>
<span>{t('adminUserAccessOverview.uizugriffsrechteBestimmenWelcheSeitenUnd')}</span>
</div>
{overview.uiAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaEye className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine UI-Berechtigungen</h3>
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.keineUiberechtigungen')}</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten UI-Berechtigungen zugewiesen.
</p>
@ -372,7 +376,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>UI-Element</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Sichtbar</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</th>
</tr>
</thead>
<tbody>
@ -422,7 +426,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{overview.dataAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaDatabase className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Daten-Berechtigungen</h3>
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.keineDatenberechtigungen')}</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Daten-Berechtigungen zugewiesen.
</p>
@ -433,10 +437,10 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Tabelle/Feld</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Lesen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Erstellen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('adminUserAccessOverview.erstellen')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Update</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Löschen</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('adminUserAccessOverview.loeschen')}</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</th>
</tr>
</thead>
<tbody>
@ -518,13 +522,13 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>Ressourcen-Zugriffsrechte bestimmen, welche System-Ressourcen (z.B. AI-Modelle) der Benutzer verwenden kann.</span>
<span>{t('adminUserAccessOverview.ressourcenzugriffsrechteBestimmenWelcheSystemressourcenZb')}</span>
</div>
{overview.resourceAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Ressourcen-Berechtigungen</h3>
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.keineRessourcenberechtigungen')}</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Ressourcen-Berechtigungen zugewiesen.
</p>
@ -535,7 +539,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Ressource</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Zugriff</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</th>
</tr>
</thead>
<tbody>
@ -590,8 +594,8 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
<p className={styles.pageSubtitle}>Zeigt alle Berechtigungen eines Benutzers an</p>
<h1 className={styles.pageTitle}>{t('adminUserAccessOverview.benutzerzugriffsuebersicht')}</h1>
<p className={styles.pageSubtitle}>{t('adminUserAccessOverview.zeigtAlleBerechtigungenEinesBenutzers')}</p>
</div>
</div>
@ -609,7 +613,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">-- Benutzer wählen --</option>
<option value="">{t('adminUserAccessOverview.benutzerWaehlen')}</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
@ -634,7 +638,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
{!selectedUserId ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Benutzer auswählen</h3>
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.benutzerAuswaehlen')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.
</p>
@ -642,7 +646,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
) : loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Zugriffsübersicht...</span>
<span>{t('adminUserAccessOverview.ladeZugriffsuebersicht')}</span>
</div>
) : overview ? (
<>

View file

@ -9,12 +9,16 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useUserMandates, type MandateUser, type Mandate, type Role, type PaginationParams } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaBuilding } from 'react-icons/fa';
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminUserMandatesPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const {
users,
@ -97,7 +101,7 @@ export const AdminUserMandatesPage: React.FC = () => {
return [
{
key: 'username',
label: 'Benutzername',
label: t('adminUserMandates.benutzername'),
type: 'text' as any,
sortable: true,
filterable: true,
@ -106,7 +110,7 @@ export const AdminUserMandatesPage: React.FC = () => {
},
{
key: 'email',
label: 'E-Mail',
label: t('adminUserMandates.email'),
type: 'text' as any,
sortable: true,
filterable: true,
@ -115,7 +119,7 @@ export const AdminUserMandatesPage: React.FC = () => {
},
{
key: 'fullName',
label: 'Vollständiger Name',
label: t('adminUserMandates.vollstaendigerName'),
type: 'text' as any,
sortable: true,
filterable: true,
@ -124,7 +128,7 @@ export const AdminUserMandatesPage: React.FC = () => {
},
{
key: 'roleLabels',
label: 'Rollen',
label: t('adminUserMandates.rollen'),
type: 'text' as any,
sortable: false,
filterable: false,
@ -137,7 +141,7 @@ export const AdminUserMandatesPage: React.FC = () => {
},
{
key: 'enabled',
label: 'Aktiv',
label: t('adminUserMandates.aktiv'),
type: 'boolean' as any,
sortable: true,
filterable: true,
@ -145,7 +149,7 @@ export const AdminUserMandatesPage: React.FC = () => {
width: 80,
},
];
}, []); // No dependencies - columns are static, roleLabels come from backend
}, [t]);
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
@ -274,7 +278,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>
<p className={styles.pageSubtitle}>Verwalten Sie, welche Benutzer Zugriff auf welche Mandanten haben</p>
<p className={styles.pageSubtitle}>{t('adminUserMandates.verwaltenSieWelcheBenutzerZugriff')}</p>
</div>
</div>
@ -290,7 +294,7 @@ export const AdminUserMandatesPage: React.FC = () => {
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('adminUserMandates.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
@ -323,7 +327,7 @@ export const AdminUserMandatesPage: React.FC = () => {
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<h3 className={styles.emptyTitle}>{t('adminUserMandates.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.
</p>
@ -345,11 +349,11 @@ export const AdminUserMandatesPage: React.FC = () => {
{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Rollen bearbeiten',
title: t('adminUserMandatesPage.editRoles'),
},
{
type: 'delete' as const,
title: 'Aus Mandant entfernen',
title: t('adminUserMandatesPage.removeFromMandate'),
}
]}
onDelete={handleRemoveUser}
@ -366,7 +370,7 @@ export const AdminUserMandatesPage: React.FC = () => {
return false;
},
}}
emptyMessage="Keine Mitglieder gefunden"
emptyMessage={t('adminUserMandates.keineMitgliederGefunden')}
/>
</div>
)}
@ -376,7 +380,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer zum Mandanten hinzufügen</h2>
<h2 className={styles.modalTitle}>{t('adminUserMandates.benutzerZumMandantenHinzufuegen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowAddModal(false)}
@ -386,11 +390,11 @@ export const AdminUserMandatesPage: React.FC = () => {
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Benutzer sind bereits diesem Mandanten zugewiesen.</p>
<p>{t('adminUserMandates.alleBenutzerSindBereitsDiesem')}</p>
) : roleOptions.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
<span>{t('adminUserMandates.ladeRollen')}</span>
</div>
) : (
<FormGeneratorForm
@ -398,8 +402,8 @@ export const AdminUserMandatesPage: React.FC = () => {
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText={isSubmitting ? 'Hinzufügen...' : 'Hinzufügen'}
cancelButtonText="Abbrechen"
submitButtonText={isSubmitting ? t('adminUserMandates.hinzufuegen') : t('adminUserMandates.hinzufuegen')}
cancelButtonText={t('adminUserMandates.abbrechen')}
/>
)}
</div>
@ -427,8 +431,8 @@ export const AdminUserMandatesPage: React.FC = () => {
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
submitButtonText={isSubmitting ? t('adminUserMandates.speichern') : t('adminUserMandates.speichern')}
cancelButtonText={t('adminUserMandates.abbrechen')}
/>
</div>
</div>

View file

@ -9,9 +9,11 @@ import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface User {
id: string;
username: string;
@ -23,6 +25,8 @@ interface User {
}
export const AdminUsersPage: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
// Use two hooks: one for data, one for operations
const {
@ -141,8 +145,8 @@ export const AdminUsersPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Benutzer</h1>
<p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p>
<h1 className={styles.pageTitle}>{t('adminUsers.benutzer')}</h1>
<p className={styles.pageSubtitle}>{t('adminUsers.verwaltenSieAlleBenutzerIm')}</p>
</div>
<div className={styles.headerActions}>
<button
@ -193,11 +197,11 @@ export const AdminUsersPage: React.FC = () => {
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
title: t('adminUsersPage.edit'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
title: t('adminUsersPage.delete'),
}] : []),
]}
customActions={canUpdate ? [
@ -205,7 +209,7 @@ export const AdminUsersPage: React.FC = () => {
id: 'sendPasswordLink',
icon: <FaKey />,
onClick: handleSendPassword,
title: 'Passwort-Link senden',
title: t('adminUsersPage.sendPasswordLink'),
loading: (row: User) => sendingPasswordLinkState.has(row.id),
}
] : []}
@ -218,7 +222,7 @@ export const AdminUsersPage: React.FC = () => {
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Benutzer gefunden"
emptyMessage={t('adminUsers.keineBenutzerGefunden')}
/>
</div>
@ -227,7 +231,7 @@ export const AdminUsersPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neuer Benutzer</h2>
<h2 className={styles.modalTitle}>{t('adminUsers.neuerBenutzer')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
@ -239,7 +243,7 @@ export const AdminUsersPage: React.FC = () => {
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminUsers.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -247,8 +251,8 @@ export const AdminUsersPage: React.FC = () => {
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
submitButtonText={t('adminUsers.erstellen')}
cancelButtonText={t('adminUsers.abbrechen')}
/>
)}
</div>
@ -261,7 +265,7 @@ export const AdminUsersPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer bearbeiten</h2>
<h2 className={styles.modalTitle}>{t('adminUsers.benutzerBearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}
@ -273,7 +277,7 @@ export const AdminUsersPage: React.FC = () => {
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('adminUsers.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -282,8 +286,8 @@ export const AdminUsersPage: React.FC = () => {
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('adminUsers.speichern')}
cancelButtonText={t('adminUsers.abbrechen')}
/>
)}
</div>

View file

@ -10,6 +10,8 @@ import { TextField } from '../../components/UiComponents/TextField';
import { useBilling } from '../../hooks/useBilling';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
@ -35,8 +37,7 @@ export interface ChatbotConfigSectionProps {
onAllowedProvidersChange: (providers: string[]) => void;
}
export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
connectors,
export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({ connectors,
systemPrompt,
enableWebResearch,
allowedProviders,
@ -45,6 +46,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
onEnableWebResearchChange,
onAllowedProvidersChange,
}) => {
const { t } = useLanguage();
const { allowedProviders: availableProviders, loadAllowedProviders, loading: providersLoading } = useBilling();
useEffect(() => {
@ -54,7 +56,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
}, []);
const availableConnectors = [
{ id: 'preprocessor', label: 'Althaus Preprocessor', value: 'preprocessor' }
{ id: 'preprocessor', label: t('chatbotConfigSection.althausPreprocessor'), value: 'preprocessor' }
];
const handleConnectorToggle = (connectorValue: string) => {
@ -110,7 +112,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
onChange={(e) => onEnableWebResearchChange(e.target.checked)}
className={styles.multiselectCheckbox}
/>
<span>Web Research aktivieren (Tavily)</span>
<span>{t('chatbotConfigSection.webResearchAktivierenTavily')}</span>
</label>
<p className={styles.configHelpText}>
Wenn aktiviert, führt der Chatbot zusätzlich Web-Recherchen mit Tavily durch, um aktuelle Informationen aus dem Internet zu finden.
@ -123,7 +125,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
</label>
<div className={styles.multiselectContainer}>
{providersLoading ? (
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Lade Anbieter...</span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{t('chatbotConfigSection.ladeAnbieter')}</span>
) : (
availableProviders.map(provider => (
<label key={provider} className={styles.multiselectOption}>
@ -155,7 +157,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
type="text"
value={systemPrompt}
onChange={onSystemPromptChange}
placeholder="Benutzerdefinierter System-Prompt für den Chatbot..."
placeholder={t('chatbotConfigSection.benutzerdefinierterSystempromptFuerDenChatbot')}
className={styles.configTextArea}
size="md"
rows={6}

View file

@ -15,6 +15,8 @@ import { PermissionMatrix } from './PermissionMatrix';
import styles from './Admin.module.css';
import modalStyles from './InstanceDetailModal.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface InstanceDetailModalProps {
instance: FeatureInstance;
mandateId: string;
@ -24,8 +26,7 @@ export interface InstanceDetailModalProps {
onSaved: () => void;
}
export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
instance,
export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instance,
mandateId,
mandateName,
featureLabel,
@ -42,6 +43,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
updateInstance,
} = useFeatureAccess();
const { showSuccess, showError } = useToast();
const { t } = useLanguage();
const [users, setUsers] = useState<FeatureAccessUser[]>([]);
const [roles, setRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
@ -182,7 +184,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
() => [
{
name: 'userId',
label: 'Benutzer',
label: t('instanceDetailModal.benutzer'),
type: 'enum' as const,
required: true,
options: availableUsers.map((u) => ({
@ -192,7 +194,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
},
{
name: 'roleIds',
label: 'Rollen',
label: t('instanceDetailModal.rollen'),
type: 'multiselect' as const,
required: true,
options: roleOptions as AttributeDefinition['options'],
@ -205,14 +207,14 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
() => [
{
name: 'roleIds',
label: 'Rollen',
label: t('instanceDetailModal.rollen'),
type: 'multiselect' as const,
required: true,
options: roleOptions as AttributeDefinition['options'],
},
{
name: 'enabled',
label: 'Aktiv',
label: t('instanceDetailModal.aktiv'),
type: 'checkbox' as const,
required: false,
},
@ -223,13 +225,13 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
const tabs = [
{
id: 'users',
label: 'Benutzer',
label: t('instanceDetailModal.benutzer'),
content: (
<div className={modalStyles.tabContent}>
{loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
<span>{t('instanceDetailModal.ladeBenutzer')}</span>
</div>
) : (
<PermissionMatrix
@ -245,7 +247,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
},
{
id: 'roles',
label: 'Rollen',
label: t('instanceDetailModal.rollen'),
content: (
<div className={modalStyles.tabContent}>
<p className={modalStyles.rolesIntro}>
@ -269,19 +271,19 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
},
{
id: 'settings',
label: 'Einstellungen',
label: t('instanceDetailModal.einstellungen'),
content: (
<div className={modalStyles.tabContent}>
<FormGeneratorForm
attributes={[
{ name: 'label', type: 'string' as const, label: 'Bezeichnung', required: true, editable: true },
{ name: 'enabled', type: 'boolean' as const, label: 'Aktiviert', required: false, editable: true },
{ name: 'label', type: 'string' as const, label: t('instanceDetailModal.bezeichnung'), required: true, editable: true },
{ name: 'enabled', type: 'boolean' as const, label: t('instanceDetailModal.aktiviert'), required: false, editable: true },
]}
data={instance}
mode="edit"
onSubmit={handleUpdateInstance}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('instanceDetailModal.speichern')}
cancelButtonText={t('instanceDetailModal.abbrechen')}
onCancel={() => {}}
/>
</div>
@ -299,7 +301,7 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
{mandateName} · {featureLabel}
</p>
</div>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Schließen">
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('instanceDetailModal.schliessen')}>
</button>
</div>
@ -312,24 +314,24 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer hinzufügen</h2>
<h2 className={styles.modalTitle}>{t('instanceDetailModal.benutzerHinzufuegen')}</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
</button>
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Mandanten-Benutzer haben bereits Zugriff.</p>
<p>{t('instanceDetailModal.alleMandantenbenutzerHabenBereitsZugriff')}</p>
) : addUserFields.length < 2 || !roleOptions?.length ? (
<p>Laden...</p>
<p>{t('instanceDetailModal.laden')}</p>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
cancelButtonText="Abbrechen"
submitButtonText={t('instanceDetailModal.hinzufuegen')}
cancelButtonText={t('instanceDetailModal.abbrechen')}
/>
)}
</div>
@ -353,8 +355,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
mode="edit"
onSubmit={handleUpdateRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('instanceDetailModal.speichern')}
cancelButtonText={t('instanceDetailModal.abbrechen')}
/>
</div>
</div>

View file

@ -14,6 +14,8 @@ import type { Mandate } from '../../hooks/useUserMandates';
import hubStyles from './AccessManagementHub.module.css';
import hierarchyStyles from './InstanceHierarchyView.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface InstanceHierarchyViewProps {
mandates: Mandate[];
getMandateName: (mandate: Mandate) => string;
@ -53,6 +55,7 @@ function MandateContent({
getFeatureLabel,
onOpenDetail,
}: MandateContentProps) {
const { t } = useLanguage();
const [expandedInstanceIds, setExpandedInstanceIds] = useState<Set<string>>(new Set());
const byFeature = useMemo(() => {
@ -64,7 +67,6 @@ function MandateContent({
return map;
}, [instances]);
const toggleInstance = (instanceId: string) => {
setExpandedInstanceIds((prev) => {
const next = new Set(prev);
@ -113,7 +115,7 @@ function MandateContent({
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? 'Aktiv' : 'Inaktiv'}
{inst.enabled ? t('instanceHierarchy.aktiv') : t('instanceHierarchy.inaktiv')}
</span>
<span className={hierarchyStyles.instanceUserCount}>
<FaUsers /> {users.length}
@ -126,7 +128,7 @@ function MandateContent({
e.stopPropagation();
onOpenDetail(inst, mandateId);
}}
title="Benutzer verwalten"
title={t('instanceHierarchy.benutzerVerwalten')}
>
<FaUsers /> Benutzer verwalten
</button>
@ -170,6 +172,7 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
loading,
onOpenDetail,
}) => {
const { t } = useLanguage();
const [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
const toggleMandate = (mandateId: string) => {
@ -186,7 +189,7 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
<section className={hubStyles.section}>
<div className={hierarchyStyles.hierarchyLoading}>
<span className={hierarchyStyles.spinner} />
<span>Lade Hierarchie und Benutzer...</span>
<span>{t('instanceHierarchy.ladeHierarchieUndBenutzer')}</span>
</div>
</section>
);
@ -195,7 +198,7 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
if (mandates.length === 0) {
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Hierarchie</h2>
<h2 className={hubStyles.sectionTitle}>{t('instanceHierarchy.hierarchie')}</h2>
<div className={hierarchyStyles.emptyHierarchy}>
Keine Mandanten vorhanden. Legen Sie unter &quot;Mandanten verwalten&quot; einen Mandanten an.
</div>
@ -205,7 +208,7 @@ export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Hierarchie</h2>
<h2 className={hubStyles.sectionTitle}>{t('instanceHierarchy.hierarchie')}</h2>
<div className={hierarchyStyles.hierarchyRoot}>
{mandates.map((mandate) => {
const mandateId = mandate.id;
@ -259,12 +262,13 @@ interface UserRowProps {
}
function UserRow({ user }: UserRowProps) {
const { t } = useLanguage();
const displayName = user.fullName?.trim() || user.username || user.userId;
const rolesText =
user.roleLabels && user.roleLabels.length > 0
? user.roleLabels.join(', ')
: 'Keine Rollen';
const statusText = user.enabled ? 'Aktiv' : 'Inaktiv';
const statusText = user.enabled ? t('instanceHierarchy.aktiv') : t('instanceHierarchy.inaktiv');
return (
<div className={hierarchyStyles.userRowWrapper}>

View file

@ -12,6 +12,8 @@ import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
import styles from './Admin.module.css';
import matrixStyles from './PermissionMatrix.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface PermissionMatrixProps {
users: FeatureAccessUser[];
roles: FeatureInstanceRole[];
@ -21,21 +23,21 @@ export interface PermissionMatrixProps {
disabled?: boolean;
}
export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
users,
export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({ users,
roles,
onEditUser,
onRemoveUser,
onAddUser,
disabled = false,
}) => {
const { t } = useLanguage();
const [removingId, setRemovingId] = useState<string | null>(null);
const { confirm, ConfirmDialog } = useConfirm();
const handleRemove = useCallback(async (user: FeatureAccessUser) => {
if (removingId) return;
const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, {
title: 'Benutzer entfernen',
title: t('permissionMatrix.removeUser'),
confirmLabel: 'Entfernen',
variant: 'danger',
});
@ -48,7 +50,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
if (roles.length === 0) {
return (
<div className={matrixStyles.empty}>
<p>Keine Rollen in dieser Instanz. Bitte zuerst Rollen synchronisieren.</p>
<p>{t('permissionMatrix.keineRollenInDieserInstanz')}</p>
</div>
);
}
@ -59,13 +61,13 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
<table className={matrixStyles.table}>
<thead>
<tr>
<th className={matrixStyles.cellUser}>Benutzer</th>
<th className={matrixStyles.cellUser}>{t('permissionMatrix.benutzer')}</th>
{roles.map((r) => (
<th key={r.id} className={matrixStyles.cellRole}>
{r.roleLabel}
</th>
))}
<th className={matrixStyles.cellActive}>Aktiv</th>
<th className={matrixStyles.cellActive}>{t('permissionMatrix.aktiv')}</th>
<th className={matrixStyles.cellActions}>Aktionen</th>
</tr>
</thead>
@ -111,7 +113,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
className={matrixStyles.actionBtn}
onClick={() => onEditUser(user)}
disabled={disabled}
title="Rollen bearbeiten"
title={t('permissionMatrix.rollenBearbeiten')}
>
<FaEdit />
</button>
@ -120,7 +122,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
className={`${matrixStyles.actionBtn} ${matrixStyles.actionBtnDanger}`}
onClick={() => handleRemove(user)}
disabled={disabled || removingId === user.userId}
title="Aus Instanz entfernen"
title={t('permissionMatrix.ausInstanzEntfernen')}
>
<FaTrash />
</button>

View file

@ -15,6 +15,8 @@ import { useFeatureAccess, type FeatureInstance, type FeatureInstanceRole } from
import { useToast } from '../../../contexts/ToastContext';
import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
type InviteType = 'mandate' | 'featureInstance';
interface RoleOption {
@ -74,6 +76,8 @@ const _cardStyle: React.CSSProperties = {
// =============================================================================
export const AdminInvitationWizardPage: React.FC = () => {
const { t } = useLanguage();
const { showSuccess } = useToast();
const { createInvitation } = useInvitations();
const { fetchMandates, fetchRoles, fetchAllUsers, fetchMandateUsers } = useUserMandates();
@ -169,17 +173,17 @@ export const AdminInvitationWizardPage: React.FC = () => {
const email = inviteeForm.email.trim();
const username = inviteeForm.username.trim();
if (!email && !username) {
setError('Bitte mindestens eine E-Mail-Adresse oder einen Benutzernamen angeben.');
setError(t('adminInvitationWizard.bitteMindestensEineEmailadresseOder'));
return;
}
const emailLower = email.toLowerCase();
const userLower = username.toLowerCase();
if (email && invitees.some(i => !i.isExisting && (i.email || '').toLowerCase() === emailLower)) {
setError('Diese E-Mail ist bereits in der Liste');
setError(t('adminInvitationWizard.dieseEmailIstBereitsIn'));
return;
}
if (username && invitees.some(i => !i.isExisting && (i.username || '').toLowerCase() === userLower)) {
setError('Dieser Benutzername ist bereits in der Liste');
setError(t('adminInvitationWizard.dieserBenutzernameIstBereitsIn'));
return;
}
setInvitees(prev => [...prev, {
@ -194,14 +198,14 @@ export const AdminInvitationWizardPage: React.FC = () => {
const addInviteeExisting = () => {
if (!selectedExistingUserId) {
setError('Bitte wählen Sie einen Benutzer');
setError(t('adminInvitationWizard.bitteWaehlenSieEinenBenutzer'));
return;
}
const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
if (!user) return;
const email = (user.email || '').trim();
if (invitees.some(i => i.userId === user.id)) {
setError('Dieser Benutzer ist bereits in der Liste');
setError(t('adminInvitationWizard.dieserBenutzerIstBereitsIn'));
return;
}
setInvitees(prev => [...prev, {
@ -231,7 +235,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
const handleSend = async () => {
if (!selectedMandate || invitees.length === 0) return;
if (inviteType === 'featureInstance' && !selectedInstance) {
setError('Bitte wählen Sie eine Feature-Instanz aus.');
setError(t('adminInvitationWizard.bitteWaehlenSieEineFeatureinstanz'));
return;
}
setIsLoading(true);
@ -353,7 +357,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{/* ── STEP 1: Invite type ── */}
{step === 1 && (
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Wohin möchten Sie einladen?</h3>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>{t('adminInvitationWizard.wohinMoechtenSieEinladen')}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<button
onClick={() => setInviteType('mandate')}
@ -363,7 +367,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
cursor: 'pointer', textAlign: 'left',
}}
>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>Zum Mandanten</div>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>{t('adminInvitationWizard.zumMandanten')}</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
Einladung zum Mandanten ohne spezifische Feature-Instanz
</div>
@ -376,7 +380,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
cursor: 'pointer', textAlign: 'left',
}}
>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>Zur Feature-Instanz</div>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '4px' }}>{t('adminInvitationWizard.zurFeatureinstanz')}</div>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
Einladung zu einer bestimmten Feature-Instanz mit Rolle
</div>
@ -394,10 +398,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
{step === 2 && (
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
{inviteType === 'mandate' ? 'Mandant auswählen' : 'Mandant und Feature-Instanz auswählen'}
{inviteType === 'mandate' ? t('adminInvitationWizard.mandantAuswaehlen') : t('adminInvitationWizard.mandantUndFeatureinstanzAuswaehlen')}
</h3>
<div style={{ marginBottom: '16px' }}>
<label className={styles.formLabel}>Mandant *</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.mandant')}</label>
<select
className={styles.filterSelect}
style={{ width: '100%' }}
@ -408,7 +412,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
setSelectedInstance(null);
}}
>
<option value="">-- Mandant wählen --</option>
<option value="">{t('adminInvitationWizard.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>{getMandateName(m)}</option>
))}
@ -416,9 +420,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
</div>
{inviteType === 'featureInstance' && selectedMandate && (
<div style={{ marginBottom: '16px' }}>
<label className={styles.formLabel}>Feature-Instanz *</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.featureinstanz')}</label>
{instances.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>Keine Feature-Instanzen für diesen Mandanten vorhanden.</p>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>{t('adminInvitationWizard.keineFeatureinstanzenFuerDiesenMandanten')}</p>
) : (
<select
className={styles.filterSelect}
@ -429,7 +433,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
setSelectedInstance(inst || null);
}}
>
<option value="">-- Feature-Instanz wählen --</option>
<option value="">{t('adminInvitationWizard.featureinstanzWaehlen')}</option>
{instances.map(inst => {
const baseLabel = inst.label || inst.featureCode;
const suffix = inst.enabled === false ? ' (deaktiviert)' : '';
@ -442,7 +446,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
</div>
)}
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
<button className={styles.secondaryButton} onClick={() => setStep(1)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(1)}>{t('adminInvitationWizard.larrZurueck')}</button>
<button
className={styles.primaryButton}
disabled={!canProceedStep3}
@ -458,7 +462,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{step === 3 && selectedMandate && (
<div>
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>{t('adminInvitationWizard.einladungenHinzufuegen')}</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
Für neue Benutzer: mindestens eine E-Mail <em>oder</em> ein Benutzername (vorgegeben). Ohne E-Mail wird kein Link per Mail versendet der Einladungslink kann manuell geteilt werden. Bestehende Benutzer wählen Sie im zweiten Tab.
</p>
@ -484,7 +488,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{addMode === 'email' ? (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div>
<label className={styles.formLabel}>E-Mail (optional)</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.emailOptional')}</label>
<input
className={styles.formInput}
type="email"
@ -494,14 +498,14 @@ export const AdminInvitationWizardPage: React.FC = () => {
/>
</div>
<div>
<label className={styles.formLabel}>Benutzername (optional)</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.benutzernameOptional')}</label>
<input
className={styles.formInput}
type="text"
autoComplete="off"
value={inviteeForm.username}
onChange={e => setInviteeForm(p => ({ ...p, username: e.target.value }))}
placeholder="z. B. vorname.nachname"
placeholder={t('adminInvitationWizard.zBVornamenachname')}
/>
<p style={{ fontSize: '11px', color: 'var(--text-secondary)', marginTop: '4px' }}>
Mindestens eines der beiden Felder ausfüllen. Mit Benutzername muss der Eingeladene genau diesen Namen beim Annehmen verwenden.
@ -549,25 +553,25 @@ export const AdminInvitationWizardPage: React.FC = () => {
) : (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div>
<label className={styles.formLabel}>Bestehender Benutzer *</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.bestehenderBenutzer')}</label>
<select
className={styles.filterSelect}
style={{ width: '100%' }}
value={selectedExistingUserId}
onChange={e => setSelectedExistingUserId(e.target.value)}
>
<option value="">-- Benutzer wählen --</option>
<option value="">{t('adminInvitationWizard.benutzerWaehlen')}</option>
{availableExistingUsers.map(u => (
<option key={u.id} value={u.id}>
{u.username} {u.email ? `(${u.email})` : ''}
</option>
))}
</select>
{availableExistingUsers.length === 0 && <p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>Keine weiteren Benutzer verfügbar.</p>}
{availableExistingUsers.length === 0 && <p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>{t('adminInvitationWizard.keineWeiterenBenutzerVerfuegbar')}</p>}
</div>
{roles.length > 0 && (
<div>
<label className={styles.formLabel}>Rolle *</label>
<label className={styles.formLabel}>{t('adminInvitationWizard.rolle')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{roles.map(r => (
<label key={r.id} className={styles.checkboxLabel} style={{
@ -608,9 +612,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
<th style={{ textAlign: 'left', padding: '8px' }}>{t('adminInvitationWizard.emailBenutzer')}</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
<th style={{ textAlign: 'left', padding: '8px' }}>{t('adminInvitationWizard.rollen')}</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
<th style={{ textAlign: 'right', padding: '8px' }}>Aktion</th>
</tr>
@ -641,12 +645,12 @@ export const AdminInvitationWizardPage: React.FC = () => {
</tbody>
</table>
) : (
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', marginBottom: '16px' }}>Noch keine Einladungen hinzugefügt.</p>
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', marginBottom: '16px' }}>{t('adminInvitationWizard.nochKeineEinladungenHinzugefuegt')}</p>
)}
</div>
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'space-between' }}>
<button className={styles.secondaryButton} onClick={() => setStep(2)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(2)}>{t('adminInvitationWizard.larrZurueck')}</button>
<button
className={styles.primaryButton}
disabled={invitees.length === 0}
@ -661,12 +665,12 @@ export const AdminInvitationWizardPage: React.FC = () => {
{/* ── STEP 4: Summary and send ── */}
{step === 4 && selectedMandate && !dispatchResults && (
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Zusammenfassung & Versand</h3>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>{t('adminInvitationWizard.zusammenfassungVersand')}</h3>
<div style={{ marginBottom: '16px' }}>
<strong>Art:</strong> {inviteType === 'mandate' ? 'Einladung zum Mandanten' : 'Einladung zur Feature-Instanz'}
<strong>Art:</strong> {inviteType === 'mandate' ? t('adminInvitationWizard.einladungZumMandanten') : t('adminInvitationWizard.einladungZurFeatureinstanz')}
</div>
<div style={{ marginBottom: '16px' }}>
<strong>Mandant:</strong> {getMandateName(selectedMandate)}
<strong>{t('adminInvitationWizard.mandant')}</strong> {getMandateName(selectedMandate)}
</div>
{inviteType === 'featureInstance' && selectedInstance && (
<div style={{ marginBottom: '16px' }}>
@ -686,7 +690,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
</ul>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px' }}>
<button className={styles.secondaryButton} onClick={() => setStep(3)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(3)}>{t('adminInvitationWizard.larrZurueck')}</button>
<button
className={styles.primaryButton}
disabled={isLoading}
@ -701,13 +705,13 @@ export const AdminInvitationWizardPage: React.FC = () => {
{/* ── Results ── */}
{dispatchResults && (
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>Ergebnis</h3>
<h3 style={{ margin: '0 0 16px 0', fontSize: '16px' }}>{t('adminInvitationWizard.ergebnis')}</h3>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
<th style={{ textAlign: 'left', padding: '8px' }}>{t('adminInvitationWizard.emailBenutzer')}</th>
<th style={{ textAlign: 'left', padding: '8px' }}>{t('adminInvitationWizard.status')}</th>
<th style={{ textAlign: 'left', padding: '8px' }}>{t('adminInvitationWizard.emailGesendet')}</th>
</tr>
</thead>
<tbody>

View file

@ -21,6 +21,8 @@ import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFor
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import styles from '../Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const TOTAL_STEPS = 4;
const STEP_LABELS = ['Mandant', 'Benutzer', 'Instances', 'Feature-Benutzer'];
@ -30,6 +32,8 @@ interface RoleOption {
}
export const AdminMandateWizardPage: React.FC = () => {
const { t } = useLanguage();
const { showSuccess, showWarning, showError } = useToast();
const { request } = useApiRequest();
const {
@ -133,7 +137,7 @@ export const AdminMandateWizardPage: React.FC = () => {
const data = await fetchMandatesList();
setMandates(data);
} catch {
setError('Fehler beim Laden der Mandanten');
setError(t('adminMandateWizard.fehlerBeimLadenDerMandanten'));
}
}, [fetchMandatesList]);
@ -456,10 +460,10 @@ export const AdminMandateWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary, #f8fafc)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>Benutzer</th>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>{t('adminMandateWizard.benutzer')}</th>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>E-Mail</th>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>Rollen</th>
<th style={{ padding: '8px 12px', textAlign: 'center', fontSize: '12px', fontWeight: 600 }}>Status</th>
<th style={{ padding: '8px 12px', textAlign: 'left', fontSize: '12px', fontWeight: 600 }}>{t('adminMandateWizard.rollen')}</th>
<th style={{ padding: '8px 12px', textAlign: 'center', fontSize: '12px', fontWeight: 600 }}>{t('adminMandateWizard.status')}</th>
<th style={{ padding: '8px 12px', textAlign: 'right', fontSize: '12px', fontWeight: 600 }}>Aktion</th>
</tr>
</thead>
@ -496,7 +500,7 @@ export const AdminMandateWizardPage: React.FC = () => {
background: u.enabled !== false ? '#dcfce7' : 'var(--bg-secondary)',
color: u.enabled !== false ? '#166534' : 'var(--text-secondary)',
}}>
{u.enabled !== false ? 'Aktiv' : 'Inaktiv'}
{u.enabled !== false ? t('adminMandateWizard.aktiv') : t('adminMandateWizard.inaktiv')}
</span>
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
@ -532,7 +536,7 @@ export const AdminMandateWizardPage: React.FC = () => {
) => (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div>
<label className={styles.formLabel}>Benutzer * (mehrfach möglich)</label>
<label className={styles.formLabel}>{t('adminMandateWizard.benutzerMehrfachMoeglich')}</label>
<div
style={{
maxHeight: '220px',
@ -573,7 +577,7 @@ export const AdminMandateWizardPage: React.FC = () => {
</div>
{roles.length > 0 && (
<div>
<label className={styles.formLabel}>Rollen (für alle ausgewählten Benutzer)</label>
<label className={styles.formLabel}>{t('adminMandateWizard.rollenFuerAlleAusgewaehltenBenutzer')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{roles.map(r => (
<label key={r.id} className={styles.checkboxLabel}>
@ -601,7 +605,7 @@ export const AdminMandateWizardPage: React.FC = () => {
onClick={onSubmit}
disabled={isLoading || formValue.userIds.length === 0}
>
{isLoading ? 'Hinzufügen...' : 'Hinzufügen'}
{isLoading ? t('adminMandateWizard.hinzufuegen') : t('adminMandateWizard.hinzufuegen')}
</button>
<button className={styles.secondaryButton} onClick={onCancel}>
Abbrechen
@ -668,7 +672,7 @@ export const AdminMandateWizardPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Mandanten-Verwaltung</h1>
<p className={styles.pageSubtitle}>Schritt-für-Schritt Wizard zur Mandanten-Konfiguration</p>
<p className={styles.pageSubtitle}>{t('adminMandateWizard.schrittfuerschrittWizardZurMandantenkonfiguration')}</p>
</div>
</div>
@ -688,7 +692,7 @@ export const AdminMandateWizardPage: React.FC = () => {
{/* ── STEP 1: MANDATE ── */}
{step === 1 && (
<div style={cardStyle}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px', marginTop: 0 }}>Mandant auswählen oder erstellen</h3>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px', marginTop: 0 }}>{t('adminMandateWizard.mandantAuswaehlenOderErstellen')}</h3>
{!isCreatingMandate ? (
<>
@ -723,7 +727,7 @@ export const AdminMandateWizardPage: React.FC = () => {
{mandateAttrLoading || createFormAttributes.length === 0 ? (
<div className={styles.loadingContainer} style={{ padding: '24px' }}>
<div className={styles.spinner} />
<span>Formular wird geladen...</span>
<span>{t('adminMandateWizard.formularWirdGeladen')}</span>
</div>
) : (
<FormGeneratorForm
@ -731,8 +735,8 @@ export const AdminMandateWizardPage: React.FC = () => {
mode="create"
onSubmit={handleCreateMandate}
onCancel={() => setIsCreatingMandate(false)}
submitButtonText={isLoading ? 'Erstellen...' : 'Mandant erstellen'}
cancelButtonText="Abbrechen"
submitButtonText={isLoading ? t('adminMandateWizard.erstellen') : t('adminMandateWizard.mandantErstellen')}
cancelButtonText={t('adminMandateWizard.abbrechen')}
/>
)}
</div>
@ -785,7 +789,7 @@ export const AdminMandateWizardPage: React.FC = () => {
gap: '12px',
border: '1px solid var(--border-color, #e5e7eb)',
}}>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten</div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('adminMandateWizard.rollenBearbeiten')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{mandateRoles.map(r => (
<label key={r.id} className={styles.checkboxLabel}>
@ -833,7 +837,7 @@ export const AdminMandateWizardPage: React.FC = () => {
</div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
<button className={styles.secondaryButton} onClick={() => setStep(1)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(1)}>{t('adminMandateWizard.larrZurueck')}</button>
<button className={styles.primaryButton} onClick={() => setStep(3)}>
Weiter &rarr;
</button>
@ -855,13 +859,13 @@ export const AdminMandateWizardPage: React.FC = () => {
{/* Feature Filter */}
<div style={{ marginBottom: '16px' }}>
<label className={styles.formLabel}>Feature filtern:</label>
<label className={styles.formLabel}>{t('adminMandateWizard.featureFiltern')}</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={e => setSelectedFeatureCode(e.target.value)}
>
<option value="">Alle Features</option>
<option value="">{t('adminMandateWizard.alleFeatures')}</option>
{features.map(f => (
<option key={f.code} value={f.code}>{getFeatureLabel(f.code)}</option>
))}
@ -879,7 +883,7 @@ export const AdminMandateWizardPage: React.FC = () => {
<div>
<span style={{ fontWeight: 600 }}>{inst.label}</span>
<span style={{ fontSize: '11px', color: 'var(--text-secondary)', marginLeft: '8px' }}>
{getFeatureLabel(inst.featureCode)} | {inst.enabled ? 'Aktiv' : 'Inaktiv'}
{getFeatureLabel(inst.featureCode)} | {inst.enabled ? t('adminMandateWizard.aktiv') : t('adminMandateWizard.inaktiv')}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
@ -911,26 +915,26 @@ export const AdminMandateWizardPage: React.FC = () => {
) : (
<div style={{ display: 'grid', gap: '12px', padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px' }}>
<div className={styles.formGroup}>
<label className={styles.formLabel}>Feature *</label>
<label className={styles.formLabel}>{t('adminMandateWizard.feature')}</label>
<select
className={styles.filterSelect}
style={{ width: '100%' }}
value={selectedFeatureCode}
onChange={e => setSelectedFeatureCode(e.target.value)}
>
<option value="">-- Feature wählen --</option>
<option value="">{t('adminMandateWizard.featureWaehlen')}</option>
{features.map(f => (
<option key={f.code} value={f.code}>{getFeatureLabel(f.code)}</option>
))}
</select>
</div>
<div className={styles.formGroup}>
<label className={`${styles.formLabel} ${styles.required}`}>Bezeichnung</label>
<label className={`${styles.formLabel} ${styles.required}`}>{t('adminMandateWizard.bezeichnung')}</label>
<input
className={styles.formInput}
value={instanceForm.label}
onChange={e => setInstanceForm(p => ({ ...p, label: e.target.value }))}
placeholder="z.B. Kunde A"
placeholder={t('adminMandateWizard.zbKundeA')}
/>
</div>
<label className={styles.checkboxLabel}>
@ -943,15 +947,15 @@ export const AdminMandateWizardPage: React.FC = () => {
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button className={styles.primaryButton} onClick={handleCreateInstance} disabled={isLoading || !selectedFeatureCode}>
{isLoading ? 'Erstellen...' : 'Erstellen'}
{isLoading ? t('adminMandateWizard.erstellen') : t('adminMandateWizard.erstellen')}
</button>
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(false)}>Abbrechen</button>
<button className={styles.secondaryButton} onClick={() => setIsCreatingInstance(false)}>{t('adminMandateWizard.abbrechen')}</button>
</div>
</div>
)}
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
<button className={styles.secondaryButton} onClick={() => setStep(2)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(2)}>{t('adminMandateWizard.larrZurueck')}</button>
<button
className={styles.primaryButton}
onClick={() => { if (instances.length > 0) { setSelectedInstance(instances[0]); setStep(4); } }}
@ -1002,7 +1006,7 @@ export const AdminMandateWizardPage: React.FC = () => {
gap: '12px',
border: '1px solid var(--border-color, #e5e7eb)',
}}>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Rollen bearbeiten (Feature-Instanz)</div>
<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('adminMandateWizard.rollenBearbeitenFeatureinstanz')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{instanceRoles.map(r => (
<label key={r.id} className={styles.checkboxLabel}>
@ -1055,7 +1059,7 @@ export const AdminMandateWizardPage: React.FC = () => {
</div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between' }}>
<button className={styles.secondaryButton} onClick={() => setStep(3)}>&larr; Zurück</button>
<button className={styles.secondaryButton} onClick={() => setStep(3)}>{t('adminMandateWizard.larrZurueck')}</button>
<button className={styles.primaryButton} onClick={() => {
showSuccess('Fertig', 'Konfiguration abgeschlossen!');
setSelectedInstance(null);

View file

@ -14,6 +14,8 @@ import type { Feature } from '../../../hooks/useFeatureAccess';
import styles from '../Admin.module.css';
import wizardStyles from './FeatureInstanceWizard.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
function getMandateName(m: Mandate): string {
if (typeof m.name === 'object') return m.name.de || m.name.en || Object.values(m.name)[0] || m.id;
return m.name || m.id;
@ -38,8 +40,7 @@ const STEPS = [
{ id: 'users', title: 'Benutzer (optional)' },
];
export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
mandateId: initialMandateId,
export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ mandateId: initialMandateId,
mandates,
features,
onClose,
@ -47,6 +48,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
}) => {
const { createInstance, addUserToInstance, fetchInstanceRoles } = useFeatureAccess();
const { showSuccess, showError } = useToast();
const { t } = useLanguage();
const [step, setStep] = useState(0);
const [mandateId, setMandateId] = useState(initialMandateId || '');
@ -71,10 +73,10 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
const createFields: AttributeDefinition[] = useMemo(
() => [
{ name: 'mandateId', label: 'Mandant', type: 'enum' as const, required: true, options: mandateOptions },
{ name: 'featureCode', label: 'Feature', type: 'enum' as const, required: true, options: featureOptions },
{ name: 'label', label: 'Bezeichnung', type: 'string' as const, required: true, editable: true },
{ name: 'enabled', label: 'Aktiv', type: 'boolean' as const, required: false, editable: true },
{ name: 'mandateId', label: t('featureInstanceWizard.mandant'), type: 'enum' as const, required: true, options: mandateOptions },
{ name: 'featureCode', label: t('featureInstanceWizard.feature'), type: 'enum' as const, required: true, options: featureOptions },
{ name: 'label', label: t('featureInstanceWizard.bezeichnung'), type: 'string' as const, required: true, editable: true },
{ name: 'enabled', label: t('featureInstanceWizard.aktiv'), type: 'boolean' as const, required: false, editable: true },
],
[mandateOptions, featureOptions]
);
@ -172,8 +174,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
<div className={styles.modalOverlay} onClick={onClose}>
<div className={`${styles.modal} ${wizardStyles.modal}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Instanz</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Schließen">
<h2 className={styles.modalTitle}>{t('featureInstanceWizard.neueFeatureinstanz')}</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('featureInstanceWizard.schliessen')}>
</button>
</div>
@ -204,8 +206,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
}}
onSubmit={handleStep1Submit}
onCancel={onClose}
submitButtonText="Weiter"
cancelButtonText="Abbrechen"
submitButtonText={t('featureInstanceWizard.weiter')}
cancelButtonText={t('featureInstanceWizard.abbrechen')}
/>
<label className={wizardStyles.checkLabel}>
<input
@ -240,7 +242,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
Optional: Weisen Sie Benutzern Rollen zu. Sie können dies auch später in der Zugriffsverwaltung tun.
</p>
{mandateUsers.length === 0 ? (
<p className={wizardStyles.stepText}>Keine Mandanten-Benutzer vorhanden.</p>
<p className={wizardStyles.stepText}>{t('featureInstanceWizard.keineMandantenbenutzerVorhanden')}</p>
) : (
<div className={wizardStyles.userList}>
{mandateUsers.map((u) => {
@ -258,7 +260,7 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
handleAddUserRole(u.id, rids);
}}
>
<option value=""> Keine Rolle </option>
<option value="">{t('featureInstanceWizard.keineRolle')}</option>
{instanceRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.roleLabel}

View file

@ -9,14 +9,18 @@ import React, { useState, useMemo, useEffect } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks } from 'react-icons/fa';
import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/** Wenn false: keine neue ClickUp-Verbindung über diese Seite (Buttons inaktiv). */
const isClickupConnectionUiEnabled = false;
export const ConnectionsPage: React.FC = () => {
const { t } = useLanguage();
// Use the consolidated hook
const {
data: connections,
@ -247,7 +251,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<h1 className={styles.pageTitle}>{t('connections.verbindungen')}</h1>
<p className={styles.pageSubtitle}>
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft
{isClickupConnectionUiEnabled ? ', ClickUp' : ''})
@ -258,7 +262,7 @@ export const ConnectionsPage: React.FC = () => {
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title="Microsoft Admin Consent — erteilt der App die nötigen Berechtigungen für den gesamten Tenant"
title={t('connections.microsoftAdminConsentErteiltDer')}
>
<FaShieldAlt /> Admin Consent
</button>
@ -291,7 +295,7 @@ export const ConnectionsPage: React.FC = () => {
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title="ClickUp-Konto verbinden"
title={t('connections.clickupkontoVerbinden')}
>
<FaTasks /> ClickUp
</button>
@ -317,11 +321,11 @@ export const ConnectionsPage: React.FC = () => {
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
title: t('connectionsPage.edit'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
title: t('connectionsPage.delete'),
loading: (row: Connection) => deletingConnections.has(row.id),
}] : []),
]}
@ -330,7 +334,7 @@ export const ConnectionsPage: React.FC = () => {
id: 'connect',
icon: <FaLink />,
onClick: handleConnect,
title: 'Verbinden',
title: t('connectionsPage.connect'),
visible: (row: Connection) =>
row.status !== 'active' &&
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
@ -340,7 +344,7 @@ export const ConnectionsPage: React.FC = () => {
id: 'refresh',
icon: <FaRedo />,
onClick: handleRefresh,
title: 'Token erneuern',
title: t('connectionsPage.refreshToken'),
visible: (row: Connection) => row.status === 'active',
loading: (row: Connection) => refreshingConnections.has(row.id),
},
@ -354,7 +358,7 @@ export const ConnectionsPage: React.FC = () => {
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Verbindungen gefunden"
emptyMessage={t('connections.keineVerbindungenGefunden')}
/>
</div>
@ -363,7 +367,7 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Verbindung bearbeiten</h2>
<h2 className={styles.modalTitle}>{t('connections.verbindungBearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingConnection(null)}
@ -375,7 +379,7 @@ export const ConnectionsPage: React.FC = () => {
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
<span>{t('connections.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
@ -384,8 +388,8 @@ export const ConnectionsPage: React.FC = () => {
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingConnection(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
submitButtonText={t('connections.speichern')}
cancelButtonText={t('connections.abbrechen')}
/>
)}
</div>

Some files were not shown because too many files have changed in this diff Show more