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,7 +5,11 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Titel</label>
@ -16,12 +20,13 @@ export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/>
</div>
<div>
<label>Beschreibung</label>
<label>{t('approvalNodeConfig.beschreibung')}</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder="Was genehmigt werden soll"
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,14 +5,18 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder="Kommentar eingeben..."
placeholder={t('commentNodeConfig.kommentarEingeben')}
/>
</div>
<div>
@ -26,4 +30,5 @@ export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, u
</label>
</div>
</>
);
);
};

View file

@ -5,18 +5,22 @@
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
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="Möchten Sie bestätigen?"
placeholder={t('confirmationNodeConfig.moechtenSieBestaetigen')}
/>
</div>
<div>
<label>Bestätigen-Button</label>
<label>{t('confirmationNodeConfig.bestaetigenbutton')}</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
@ -30,4 +34,5 @@ export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ para
/>
</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 }) => (
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="*/5 * * * *"
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' }}>Cron: min hour day month weekday</p>
<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 }[] = [
function _getImportModes(t: (key: string) => string): { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] {
return [
{
value: 'merge',
label: 'Zusammenführen',
description: 'Bestehende Regeln aktualisieren, neue hinzufügen',
label: t('rbacExportImport.zusammenfuehren'),
description: t('rbacExportImport.bestehendeRegelnAktualisieren'),
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: 'Nur hinzufügen',
description: 'Nur neue Regeln hinzufügen, bestehende nicht ändern',
label: t('rbacExportImport.nurHinzufuegen'),
description: t('rbacExportImport.nurNeueRegelnHinzufuegen'),
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: 'Ersetzen',
description: 'Alle bestehenden Regeln löschen und ersetzen',
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,6 +38,7 @@ export function Popup({
closable = true,
actions = []
}: PopupProps) {
const { t } = useLanguage();
// Handle escape key
React.useEffect(() => {
@ -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 { fetchAttributes as fetchAttributesApi } from '../api/attributesApi';
import type { AttributeDefinition, ApiRequestFunction } from '../api/attributesApi';
import {
deleteWorkflowApi,
deleteWorkflowsApi,
updateWorkflowApi,
fetchWorkflows as fetchWorkflowsApi,
fetchWorkflow as fetchWorkflowByIdApi,
fetchAttributes as fetchAttributesApi,
startWorkflowApi,
stopWorkflowApi,
deleteMessageApi,
deleteFileFromMessageApi,
type Workflow,
type AttributeDefinition,
type StartWorkflowRequest
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 = () => (
const LoadingScreen: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.loadingContainer}>
<div className={styles.loadingSpinner} />
<p>Lade Feature-Daten...</p>
<p>{t('ui.ladeFeaturedaten')}</p>
</div>
);
);
};
// =============================================================================
// ERROR COMPONENT
@ -31,16 +36,19 @@ interface ErrorScreenProps {
returnPath?: string;
}
const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' }) => (
const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' }) => {
const { t } = useLanguage();
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<h2>Zugriff nicht möglich</h2>
<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 = () => (
const NotFound: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.notFound}>
<h2>Seite nicht gefunden</h2>
<p>Diese View existiert nicht oder wurde noch nicht implementiert.</p>
<h2>{t('feature.seiteNichtGefunden')}</h2>
<p>{t('feature.dieseViewExistiertNichtOder')}</p>
</div>
);
);
};
const AccessDenied: React.FC = () => (
const AccessDenied: React.FC = () => {
const { t } = useLanguage();
return (
<div className={styles.accessDenied}>
<h2>Zugriff verweigert</h2>
<p>Du hast keine Berechtigung für diese Ansicht.</p>
<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>
{progress.total > 1 && (
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
{progress.current} / {progress.total}
{progress.done && !progress.error && ' — fertig'}
{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