fixed language logic items

This commit is contained in:
ValueOn AG 2026-04-11 19:44:52 +02:00
parent fbfe85f225
commit d1f0b3c3d6
155 changed files with 2091 additions and 2057 deletions

View file

@ -18,7 +18,7 @@ export interface AttributeDefinition {
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
options?: Array<{ value: string | number; label: string }> | string;
validation?: any;
ui?: any;
readonly?: boolean;

View file

@ -16,7 +16,7 @@ export interface Prompt {
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string };
label: string;
}
export interface AttributeDefinition {

View file

@ -28,7 +28,7 @@ export interface AttributeDefinition {
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
options?: Array<{ value: string | number; label: string }> | string;
validation?: any;
sortable?: boolean;
filterable?: boolean;

View file

@ -86,7 +86,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title={t('delete rule')}
title={t('Regel löschen')}
>
<FaTrash />
</button>
@ -97,7 +97,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
<div className={styles.permissionsGrid}>
{/* View Toggle */}
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>View</span>
<span className={styles.permissionLabel}>{t('Ansicht')}</span>
<div className={styles.viewToggle}>
<input
type="checkbox"
@ -113,7 +113,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
{isDataRule ? (
<>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Read</span>
<span className={styles.permissionLabel}>{t('Lesen')}</span>
<AccessLevelSelect
value={rule.read}
onChange={(value) => onUpdate(rule.id, { read: value })}
@ -122,7 +122,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Create</span>
<span className={styles.permissionLabel}>{t('Erstellen')}</span>
<AccessLevelSelect
value={rule.create}
onChange={(value) => onUpdate(rule.id, { create: value })}
@ -131,7 +131,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Update</span>
<span className={styles.permissionLabel}>{t('Bearbeiten')}</span>
<AccessLevelSelect
value={rule.update}
onChange={(value) => onUpdate(rule.id, { update: value })}
@ -140,7 +140,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>{t('delete')}</span>
<span className={styles.permissionLabel}>{t('Löschen')}</span>
<AccessLevelSelect
value={rule.delete}
onChange={(value) => onUpdate(rule.id, { delete: value })}
@ -214,7 +214,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
};
const getLabel = (obj: CatalogObject): string => {
return obj.label.de || obj.label.en || obj.objectKey;
return (typeof obj.label === 'string' ? obj.label : '') || obj.objectKey;
};
return (
@ -260,7 +260,9 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
)}
<span className={styles.formHint}>
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).
{t(
'Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).'
)}
</span>
</div>
@ -272,7 +274,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
onChange={(e) => setView(e.target.checked)}
style={{ marginRight: '0.5rem' }}
/>
Sichtbar (View)
{t('Sichtbar (Ansicht)')}
</label>
</div>
@ -290,7 +292,12 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
{(['create', 'read', 'update', 'delete'] as const).map(op => {
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' };
const labels = {
create: t('Erstellen'),
read: t('Lesen'),
update: t('Bearbeiten'),
delete: t('Löschen'),
};
return (
<div key={op} className={styles.matrixRow}>
@ -310,7 +317,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
setValue(hierarchy[idx - 1] || 'n');
}
}}
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`}
title={`${labels[op]} - ${level === 'm' ? t('Eigene') : level === 'g' ? t('Gruppe') : t('Alle')}`}
/>
</div>
))}
@ -322,10 +329,10 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
<div className={styles.formActions}>
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
Abbrechen
{t('Abbrechen')}
</button>
<button type="submit" className={styles.primaryButton}>
<FaPlus /> Hinzufügen
<FaPlus /> {t('Hinzufügen')}
</button>
</div>
</form>
@ -355,6 +362,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
onDelete,
onAdd,
}) => {
const { t } = useLanguage();
const [showAddForm, setShowAddForm] = useState(false);
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
@ -373,9 +381,12 @@ const RulesSection: React.FC<RulesSectionProps> = ({
const getEmptyText = () => {
switch (context) {
case 'DATA': return 'Keine Daten-Regeln definiert';
case 'UI': return 'Keine UI-Regeln definiert';
case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert';
case 'DATA':
return t('Keine Daten-Regeln definiert');
case 'UI':
return t('Keine UI-Regeln definiert');
case 'RESOURCE':
return t('Keine Ressourcen-Regeln definiert');
}
};
@ -384,7 +395,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
{!readOnly && !showAddForm && (
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
{rules.length} {rules.length === 1 ? t('Regel') : t('Regeln')}
</span>
<div className={styles.headerActions}>
{/* View Toggle */}
@ -393,14 +404,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
<button
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(true)}
title="Tabellenansicht"
title={t('Tabellenansicht')}
>
<FaThList />
</button>
<button
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(false)}
title="Kartenansicht"
title={t('Kartenansicht')}
>
<FaTh />
</button>
@ -410,7 +421,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
className={styles.addButton}
onClick={() => setShowAddForm(true)}
>
<FaPlus /> Neue Regel
<FaPlus /> {t('Neue Regel')}
</button>
</div>
</div>
@ -431,7 +442,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
<p className={styles.emptyText}>{getEmptyText()}</p>
{!readOnly && (
<p className={styles.emptyHint}>
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen.
{t('Klicken Sie auf „Neue Regel“, um eine Berechtigung hinzuzufügen.')}
</p>
)}
</div>
@ -469,6 +480,7 @@ interface JsonEditorProps {
}
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
const { t } = useLanguage();
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null);
@ -481,7 +493,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
try {
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
throw new Error('JSON muss ein Array sein');
throw new Error(t('JSON muss ein Array sein'));
}
setError(null);
onApply(parsed);
@ -501,8 +513,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
/>
{error && <div className={styles.jsonError}>{error}</div>}
<p className={styles.jsonHint}>
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
{t(
'Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. Änderungen werden erst nach Klick auf „Anwenden“ übernommen.'
)}
</p>
{!readOnly && (
<div className={styles.formActions}>
@ -512,7 +525,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
onClick={handleApply}
disabled={!!error}
>
JSON anwenden
{t('JSON anwenden')}
</button>
</div>
)}
@ -607,7 +620,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
setHasChanges(false);
onSave?.();
} else {
showError('Fehler', result.error || 'Fehler beim Speichern');
showError(t('Fehler'), result.error || t('Fehler beim Speichern'));
}
};
@ -655,7 +668,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
<div className={styles.editorHeader}>
<h3 className={styles.editorTitle}>
Berechtigungen{roleName ? `: ${roleName}` : ''}
{isTemplate && <span className={styles.templateBadge}>Template</span>}
{isTemplate && <span className={styles.templateBadge}>{t('Vorlage')}</span>}
</h3>
{!readOnly && hasChanges && (
<div className={styles.headerActions}>

View file

@ -77,6 +77,8 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
onDelete,
}) => {
const { t } = useLanguage();
const opTitle = (op: 'create' | 'read' | 'update' | 'delete') =>
({ create: t('Erstellen'), read: t('Lesen'), update: t('Bearbeiten'), delete: t('Löschen') })[op];
const handleLevelToggle = (
field: 'read' | 'create' | 'update' | 'delete',
targetLevel: 'm' | 'g' | 'a',
@ -112,7 +114,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={rule.view}
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
disabled={readOnly}
title="Sichtbar"
title={t('Sichtbar')}
/>
</td>
@ -127,7 +129,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'm')}
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`}
title={`${opTitle(op)} - ${t('Eigene')}`}
/>
</td>
))}
@ -140,7 +142,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'g')}
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`}
title={`${opTitle(op)} - ${t('Gruppe')}`}
/>
</td>
))}
@ -153,7 +155,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'a')}
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`}
title={`${opTitle(op)} - ${t('Alle')}`}
/>
</td>
))}
@ -166,7 +168,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title={t('delete rule')}
title={t('Regel löschen')}
>
<FaTrash />
</button>
@ -200,7 +202,7 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<thead>
<tr>
<th className={styles.colObject}>{t('object dot notation')}</th>
<th className={styles.colView}>View</th>
<th className={styles.colView}>{t('Ansicht')}</th>
{isDataContext && (
<>
<th className={styles.colGroupHeader} colSpan={4}>{t('own')}</th>
@ -214,18 +216,18 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<tr className={styles.subHeader}>
<th></th>
<th></th>
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title={t('delete')}>D</th>
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title={t('delete')}>D</th>
<th title="Create">C</th>
<th title="Read">R</th>
<th title="Update">U</th>
<th title={t('delete')}>D</th>
<th title={t('Erstellen')}>C</th>
<th title={t('Lesen')}>R</th>
<th title={t('Bearbeiten')}>U</th>
<th title={t('Löschen')}>D</th>
<th title={t('Erstellen')}>C</th>
<th title={t('Lesen')}>R</th>
<th title={t('Bearbeiten')}>U</th>
<th title={t('Löschen')}>D</th>
<th title={t('Erstellen')}>C</th>
<th title={t('Lesen')}>R</th>
<th title={t('Bearbeiten')}>U</th>
<th title={t('Löschen')}>D</th>
<th></th>
</tr>
)}

View file

@ -4,6 +4,7 @@
* Simple text input with send button, usable by both Workspace and Editor.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
interface ChatInputProps {
onSend: (message: string) => void;
@ -17,11 +18,13 @@ interface ChatInputProps {
export const ChatInput: React.FC<ChatInputProps> = ({
onSend,
isProcessing,
placeholder = 'Type a message...',
placeholder,
disabled,
autoFocus = true,
style,
}) => {
const { t } = useLanguage();
const resolvedPlaceholder = placeholder ?? t('Nachricht eingeben…');
const [value, setValue] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
@ -62,7 +65,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
disabled={isProcessing || disabled}
rows={1}
style={{
@ -95,7 +98,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
whiteSpace: 'nowrap',
}}
>
{isProcessing ? '...' : 'Send'}
{isProcessing ? '…' : t('Senden')}
</button>
</div>
);

View file

@ -7,6 +7,7 @@
import React, { useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface ChatMessage {
id: string;
@ -31,9 +32,11 @@ const _roleColors: Record<string, string> = {
export const ChatMessageList: React.FC<ChatMessageListProps> = ({
messages,
isProcessing,
emptyMessage = 'No messages yet.',
emptyMessage,
style,
}) => {
const { t } = useLanguage();
const resolvedEmpty = emptyMessage ?? t('Noch keine Nachrichten.');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -55,7 +58,7 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px', textAlign: 'center', marginTop: '24px' }}>
{emptyMessage}
{resolvedEmpty}
</div>
)}
{messages.map((msg) => (
@ -80,7 +83,7 @@ export const ChatMessageList: React.FC<ChatMessageListProps> = ({
))}
{isProcessing && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '12px', fontStyle: 'italic' }}>
Processing...
{t('Wird verarbeitet…')}
</div>
)}
<div ref={bottomRef} />

View file

@ -64,7 +64,7 @@ export function ContentPreview({
setError(t('Ungültige Datei-ID'));
return;
}
if (!fileName || fileName === 'Unknown Item') {
if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
setError(t('Dateiname nicht verfügbar'));
return;
}
@ -77,7 +77,7 @@ export function ContentPreview({
}
setError(null);
}
}, [isOpen, fileId, fileName]);
}, [isOpen, fileId, fileName, t]);
const loadPreview = async () => {
@ -95,7 +95,7 @@ export function ContentPreview({
}
// If it's text content but MIME type says PDF, we'll handle it in renderPreview
} else {
setError(result.error || 'Failed to load preview');
setError(result.error || t('Vorschau konnte nicht geladen werden.'));
}
} catch (err) {
setError(t('Ein unerwarteter Fehler ist aufgetreten, während'));
@ -201,7 +201,7 @@ export function ContentPreview({
</div>
<pre className={styles.jsonPreview}>
<code className={styles.jsonCode}>
{previewContent || 'No content available'}
{previewContent || t('Kein Inhalt verfügbar')}
</code>
</pre>
</div>

View file

@ -96,7 +96,9 @@ export function UrlContentPreview({
const warningTimeout = setTimeout(() => {
if (isLoading && !hasLoaded) {
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.');
setWarning(
t('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.')
);
// Don't set isLoading to false - let it continue
}
}, WARNING_TIMEOUT);
@ -107,7 +109,7 @@ export function UrlContentPreview({
console.log('PDF loading timeout, switching to PDF.js fallback');
setUsePdfJs(true);
setIsLoading(true); // Restart loading with PDF.js
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...');
setWarning(t('PDF lädt langsam. Alternative Anzeigemethode wird versucht…'));
} else if (isLoading && !hasLoaded && usePdfJs) {
// PDF.js also failed, show error
setShowPdfAnyway(true);
@ -121,7 +123,7 @@ export function UrlContentPreview({
clearTimeout(errorTimeout);
};
}
}, [isOpen, isLoading, hasLoaded, usePdfJs]);
}, [isOpen, isLoading, hasLoaded, usePdfJs, t]);
// Validate URL
useEffect(() => {
@ -184,7 +186,7 @@ export function UrlContentPreview({
padding: '0.5rem 1rem'
}}
>
In neuem Tab öffnen
{t('In neuem Tab öffnen')}
</button>
<button
onClick={handleDownload}
@ -195,7 +197,7 @@ export function UrlContentPreview({
padding: '0.5rem 1rem'
}}
>
Download
{t('Herunterladen')}
</button>
</div>
</div>
@ -241,7 +243,7 @@ export function UrlContentPreview({
fontWeight: '500'
}}
>
In neuem Tab öffnen
{t('In neuem Tab öffnen')}
</button>
<button
onClick={handleDownload}
@ -253,7 +255,7 @@ export function UrlContentPreview({
fontWeight: '500'
}}
>
Download File
{t('Datei herunterladen')}
</button>
</div>
</div>
@ -284,7 +286,7 @@ export function UrlContentPreview({
fontWeight: '500'
}}
>
In neuem Tab öffnen
{t('In neuem Tab öffnen')}
</button>
<button
onClick={handleDownload}
@ -296,7 +298,7 @@ export function UrlContentPreview({
fontWeight: '500'
}}
>
Download
{t('Herunterladen')}
</button>
</div>
</div>
@ -316,7 +318,7 @@ export function UrlContentPreview({
<div className={styles.fileName}>{fileName}</div>
<p>{t('Vorschau wird hierfür nicht unterstützt')}</p>
<button onClick={handleDownload} className={styles.retryButton}>
Download File
{t('Datei herunterladen')}
</button>
</div>
);

View file

@ -303,7 +303,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
<button
className={styles.collapseButton}
onClick={() => toggleCollapse(rowPath)}
title={isCollapsed ? 'Expand' : 'Collapse'}
title={isCollapsed ? t('Aufklappen') : t('Einklappen')}
>
{isCollapsed ? '▶' : '▼'}
</button>
@ -479,7 +479,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
);
} catch (parseError) {
const rawData = {
keys: ['Raw Content'],
keys: [t('Rohinhalt')],
values: [previewContent],
types: ['string'],
isNested: [false]

View file

@ -66,7 +66,7 @@ export function PdfJsRenderer({
} catch (err) {
console.error('Error loading PDF with PDF.js:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load PDF');
setError(err instanceof Error ? err.message : t('PDF konnte nicht geladen werden.'));
setIsLoading(false);
onError();
}
@ -116,7 +116,7 @@ export function PdfJsRenderer({
} catch (err) {
console.error('Error rendering PDF page:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to render PDF page');
setError(err instanceof Error ? err.message : t('PDF-Seite konnte nicht gerendert werden.'));
}
}
};
@ -132,7 +132,9 @@ export function PdfJsRenderer({
return (
<div className={styles.errorContainer}>
<div className={styles.errorIcon}></div>
<p>Fehler beim Laden der PDF: {error}</p>
<p>
{t('Fehler beim Laden der PDF:')} {error}
</p>
</div>
);
}

View file

@ -57,8 +57,8 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[Automation2]';
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen');
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
interface Automation2FlowEditorProps {
instanceId: string;
@ -106,7 +106,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
@ -176,7 +178,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : DEFAULT_INVOCATIONS();
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
@ -189,7 +191,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
},
[nodeTypes, language]
[nodeTypes, language, t]
);
const handleFromApiGraph = useCallback(
@ -202,7 +204,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleExecute = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
return;
}
setExecuting(true);
@ -222,12 +224,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return;
}
setSaving(true);
@ -236,17 +238,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = await promptInput('Workflow-Name:', {
const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'),
defaultValue: 'Neuer Workflow',
placeholder: 'Name des Workflows',
defaultValue: t('Neuer Workflow'),
placeholder: t('Name des Workflows'),
});
if (!label) {
setSaving(false);
return;
}
const created = await createWorkflow(request, instanceId, {
label: label.trim() || 'Neuer Workflow',
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
});
@ -260,7 +262,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -287,17 +289,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}
},
[handleLoad, applyGraphWithSync]
[handleLoad, applyGraphWithSync, t]
);
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}, [applyGraphWithSync]);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
@ -401,7 +403,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [
loading,
nodeTypes.length,
@ -409,6 +411,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
initialWorkflowId,
canvasNodes.length,
applyGraphWithSync,
t,
]);
const toggleCategory = useCallback((id: string) => {
@ -591,7 +594,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
@ -604,12 +607,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={loadNodeTypes}>
Erneut versuchen
{t('Erneut versuchen')}
</button>
</div>
</div>
@ -648,7 +651,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)}
>
{{ chats: 'Chats', files: 'Dateien', sources: 'Quellen' }[tab]}
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
</button>
))}
</div>
@ -755,13 +758,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
onClick={() => setRightTab('nodes')}
>
Nodes
{t('Knoten')}
</button>
<button
className={`${styles.rightTab} ${rightTab === 'tracing' ? styles.rightTabActive : ''}`}
onClick={() => { setRightTab('tracing'); if (!tracingRunId) setTracingRunId('select'); }}
>
Tracing
{t('Ablaufverfolgung')}
</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>

View file

@ -2,7 +2,7 @@
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
@ -118,7 +118,15 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
return () => document.removeEventListener('mousedown', _handleClickOutside);
}, []);
const SCOPE_LABELS: Record<string, string> = { user: 'Meine Vorlagen', instance: 'Instanz', mandate: 'Mandant' };
const scopeLabels = useMemo(
() =>
({
user: t('Meine Vorlagen'),
instance: t('Instanz'),
mandate: t('Mandant'),
}) as Record<string, string>,
[t]
);
return (
<div className={styles.canvasHeader}>
@ -139,14 +147,14 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
className={styles.canvasTitle}
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
onClick={_startNameEdit}
title={onWorkflowRename ? 'Klicken zum Umbenennen' : undefined}
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
Neuer Workflow
{t('Neuer Workflow')}
</h4>
)}
{onWorkflowSettings && (
@ -154,7 +162,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label="Workflow-Konfiguration"
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
@ -165,7 +173,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ display: 'flex' }}>
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
Neu
{t('Neu')}
</button>
<button
type="button"
@ -184,7 +192,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onClick={() => { onNew(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
>
Leerer Workflow
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
@ -192,7 +200,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
>
Aus Vorlage...
{t('Aus Vorlage…')}
</button>
)}
</div>
@ -205,7 +213,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onClick={onSave}
disabled={saving || !hasNodes}
>
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{/* Save as template */}
@ -229,7 +237,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
>
{SCOPE_LABELS[s]}
{scopeLabels[s]}
</button>
))}
</div>
@ -260,19 +268,19 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
Ausführen
{t('Ausführen…')}
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
Ausführen
{t('Ausführen')}
</>
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
Workspace
{t('Workspace')}
</button>
)}
</div>
@ -280,7 +288,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{/* Version Selector */}
{currentWorkflowId && versions && versions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>Version:</span>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
<select
value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)}
@ -316,7 +324,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
Publish
{t('Veröffentlichen')}
</button>
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
@ -329,7 +337,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
Unpublish
{t('Veröffentlichung aufheben')}
</button>
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
@ -388,7 +396,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
Task zu bearbeiten.
</>
) : (
<> {executeResult.error ?? 'Unbekannter Fehler'}</>
<> {executeResult.error ?? t('Unbekannter Fehler')}</>
)}
</div>
)}

View file

@ -130,29 +130,29 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
},
onComplete: () => {
if (!accumulated) {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: 'Done.' } : m));
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: t('Fertig.') } : m));
}
onGraphUpdated?.();
setLoading(false);
},
onError: (event) => {
const errText = event.content || 'Request failed';
const errText = event.content || t('Anfrage fehlgeschlagen');
if (!accumulated) {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m));
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${errText}` } : m));
}
setLoading(false);
},
onStopped: () => setLoading(false),
},
onConnectionError: (err) => {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err.message}` } : m));
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
setLoading(false);
},
onStreamEnd: () => setLoading(false),
});
abortRef.current = cleanup;
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds]);
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -325,7 +325,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
minWidth: 220, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
{t('Aktive Quellen auswählen')}
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
@ -354,7 +354,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
{t('Feature-Datenquellen')}
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
@ -394,7 +394,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<button onClick={() => abortRef.current?.()} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
}}>Stop</button>
}}>{t('Stopp')}</button>
) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',

View file

@ -560,17 +560,30 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
>
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
<div className={styles.connectionHint}>
{selectedNodeIds.size} Nodes ausgewählt <kbd>Entf</kbd> zum Löschen Ziehen zum Verschieben <kbd>Shift</kbd>+Klick zum Hinzufügen/Entfernen
{selectedNodeIds.size} {t('Knoten ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Ziehen zum Verschieben')}
{' · '}
<kbd>Shift</kbd>
{t('+Klick zum Hinzufügen oder Entfernen')}
</div>
)}
{connectingFrom && !selectedConnectionId && (
<div className={styles.connectionHint}>
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang <kbd>Esc</kbd> zum Abbrechen
{t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')}
{' · '}
<kbd>Esc</kbd> {t('zum Abbrechen')}
</div>
)}
{selectedConnectionId && (
<div className={styles.connectionHint}>
Pfeil ausgewählt <kbd>Entf</kbd> zum Löschen Klicken Sie auf einen anderen Eingang zum Umleiten
{t('Verbindungspfeil ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Anderen Eingang anklicken zum Umleiten')}
</div>
)}
<div
@ -840,7 +853,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
)}
{nodes.length === 0 && (
<div className={styles.canvasPlaceholder}>
<p>{t('Nodes aus der Liste links')}</p>
<p>{t('Knoten aus der Liste links ziehen')}</p>
</div>
)}
</div>

View file

@ -91,7 +91,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
placeholder={t('z.B. Kundenformular prüfen, Land')}
/>
<p className={styles.nodeConfigNameHint}>
Wird im Data Picker angezeigt, um diesen Node zu identifizieren.
{t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}
</p>
</div>
)}

View file

@ -84,7 +84,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
return (
<div className={styles.sidebar} style={style}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
<input
type="text"
className={styles.sidebarSearch}

View file

@ -172,7 +172,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
if (!runId) {
return (
<div style={{ padding: '16px', color: 'var(--text-secondary, #888)', fontSize: '13px' }}>
Select a run to see tracing details.
{t('Run auswählen, um Tracing-Details zu sehen.')}
</div>
);
}
@ -180,7 +180,10 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
return (
<div style={{ padding: '12px', overflowY: 'auto', height: '100%' }}>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '12px' }}>
Run Steps {loading && <span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>(loading...)</span>}
{t('Run-Schritte')}{' '}
{loading && (
<span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>({t('wird geladen…')})</span>
)}
</div>
{steps.length === 0 && !loading && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>{t('Noch keine Schritte aufgezeichnet')}</div>
@ -223,7 +226,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{step.retryCount > 0 && (
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title={t('Wiederholungsanzahl')}>
{step.retryCount}x retry
{step.retryCount}x {t('Wiederholung')}
</span>
)}
{step.durationMs != null && (
@ -244,11 +247,13 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
)}
{step.tokensUsed > 0 && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>{step.tokensUsed} tokens</div>
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
{step.tokensUsed} {t('Tokens')}
</div>
)}
<CollapsibleSection label="Input" content={inputStr} />
<CollapsibleSection label="Output" content={outputStr} />
<CollapsibleSection label={t('Eingabe')} content={inputStr} />
<CollapsibleSection label={t('Ausgabe')} content={outputStr} />
</div>
);
})}

View file

@ -2,7 +2,7 @@
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSpinner } from 'react-icons/fa';
import {
fetchTemplates,
@ -11,14 +11,7 @@ import {
type ApiRequestFunction,
} from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
const SCOPE_LABELS: Record<AutoTemplateScope | 'all', string> = {
all: 'Alle',
user: 'Meine',
instance: 'Instanz',
mandate: 'Mandant',
system: 'System',
};
import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps {
open: boolean;
@ -35,6 +28,18 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
instanceId,
request,
}) => {
const { t } = useLanguage();
const scopeLabels = useMemo(
() =>
({
all: t('Alle'),
user: t('Meine'),
instance: t('Instanz'),
mandate: t('Mandant'),
system: t('System'),
}) as Record<AutoTemplateScope | 'all', string>,
[t]
);
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
@ -76,10 +81,10 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
Neu aus Vorlage
{t('Neu aus Vorlage')}
</h3>
<p className={styles.workflowModalHint}>
Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.
{t('Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.')}
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
@ -91,7 +96,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
onClick={() => setActiveScope(s)}
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
>
{SCOPE_LABELS[s]}
{scopeLabels[s]}
</button>
))}
</div>
@ -103,14 +108,14 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
</div>
) : templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
Keine Vorlagen gefunden.
{t('Keine Vorlagen gefunden.')}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
<th style={{ padding: '6px 8px' }}>Name</th>
<th style={{ padding: '6px 8px', width: 80 }}>Scope</th>
<th style={{ padding: '6px 8px' }}>{t('Name')}</th>
<th style={{ padding: '6px 8px', width: 80 }}>{t('Scope')}</th>
<th style={{ padding: '6px 8px', width: 100 }}></th>
</tr>
</thead>
@ -119,7 +124,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '8px' }}>{tpl.label}</td>
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
{SCOPE_LABELS[(tpl.templateScope as AutoTemplateScope) || 'user']}
{scopeLabels[(tpl.templateScope as AutoTemplateScope) || 'user']}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
<button
@ -129,7 +134,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
onClick={() => _handleSelect(tpl.id)}
disabled={copying !== null}
>
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : 'Übernehmen'}
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : t('Übernehmen')}
</button>
</td>
</tr>
@ -141,7 +146,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
Abbrechen
{t('Abbrechen')}
</button>
</div>
</div>

View file

@ -64,7 +64,7 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || 'Start';
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
@ -74,15 +74,16 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
<div className={styles.workflowModal}>
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
Workflow-Konfiguration
{t('Workflow-Konfiguration')}
</h3>
<p className={styles.workflowModalHint}>
Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem
gewählten Einstieg an (z.&nbsp;B. Formular-Felder auf der Start-Node bearbeiten).
{t(
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
)}
</p>
<form onSubmit={handleSubmit}>
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
Titel der Start Node
{t('Titel der Start Node')}
</label>
<input
id="wf-start-title"
@ -92,7 +93,7 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
placeholder={t('z.B. Angebot anlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label="Einstiegsart">
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
{kindOptions.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
@ -109,10 +110,10 @@ export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProp
<div className={styles.workflowModalActions}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
Abbrechen
{t('Abbrechen')}
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
Übernehmen
{t('Übernehmen')}
</button>
</div>
</form>

View file

@ -57,7 +57,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
return (
<div>
<label>Felder</label>
<label>{t('Felder')}</label>
<div className={styles.formFieldsList}>
{fields.map((f, i) => (
<div
@ -87,7 +87,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
</span>
<div className={styles.formFieldInputs}>
<input
placeholder="name"
placeholder={t('name')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
@ -96,7 +96,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
}}
/>
<input
placeholder="label"
placeholder={t('label')}
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
@ -111,13 +111,13 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type ?? 'string'}
onChange={(e) => {
const next = [...fields];
const t = e.target.value;
const fieldType = e.target.value;
next[i] = {
...next[i],
type: t,
...(t === 'clickup_tasks'
type: fieldType,
...(fieldType === 'clickup_tasks'
? { clickupStatusOptions: undefined }
: t === 'clickup_status'
: fieldType === 'clickup_status'
? { clickupConnectionId: undefined, clickupListId: undefined }
: {
clickupConnectionId: undefined,
@ -129,10 +129,10 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
<option value="string">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Kontrollkästchen')}</option>
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
</select>
@ -146,7 +146,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam('fields', next);
}}
/>
Pflichtfeld
{t('Pflichtfeld')}
</label>
<button
type="button"
@ -161,13 +161,16 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
<p style={{ margin: '0 0 6px' }}>
Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter
Status-Name für die API).
{t(
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
{ count: String(f.clickupStatusOptions.length) }
)}
</p>
) : (
<p style={{ margin: '0 0 6px' }}>
Keine Optionen im ClickUp-Knoten Aufgabe erstellen Liste wählen und Formular mit Liste
abgleichen.
{t(
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
)}
</p>
)}
</div>
@ -175,7 +178,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
{f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
ClickUp-Verbindung
{t('ClickUp-Verbindung')}
</label>
<select
value={f.clickupConnectionId ?? ''}
@ -187,7 +190,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">{connectionsLoading ? 'Lade…' : 'Verbindung wählen…'}</option>
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
@ -195,7 +198,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
))}
</select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
Listen-ID (verknüpfte Liste / Ziel-Liste)
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
</label>
<input
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
@ -208,9 +211,9 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
style={{ width: '100%' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '}
<code>{'{ add: [taskId], rem: [] }'}</code> im ClickUp-Node per Datenquelle auf das
Formularfeld mappen.
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
</p>
</div>
) : null}
@ -222,7 +225,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
}
>
+ Feld
+ {t('Feld')}
</button>
</div>
</div>

View file

@ -180,6 +180,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
};
const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dependsOn = param.frontendOptions?.dependsOn as string | undefined;
const depValue = dependsOn ? allParams?.[dependsOn] : undefined;
const disabled = dependsOn && !depValue;
@ -191,7 +192,7 @@ const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, al
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
disabled={!!disabled}
placeholder={disabled ? `Select ${dependsOn} first` : param.name}
placeholder={disabled ? t('Zuerst {field} wählen', { field: dependsOn ?? '' }) : param.name}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: disabled ? 0.5 : 1 }}
/>
</div>
@ -214,9 +215,9 @@ const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }
{cases.map((c: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">equals</option>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">contains</option>
<option value="contains">{t('enthält')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
@ -244,18 +245,18 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{fields.map((f: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<input type="text" placeholder="Name" value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="checkbox">Checkbox</option>
<option value="select">Select</option>
<option value="textarea">Textarea</option>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="checkbox">{t('Kontrollkästchen')}</option>
<option value="select">{t('Auswahl')}</option>
<option value="textarea">{t('Mehrzeilig')}</option>
</select>
<input type="text" placeholder="Label" value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> Req
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
</label>
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
@ -280,8 +281,8 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{rows.map((r: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input type="text" placeholder="Key" value={String(r.key ?? r.fieldKey ?? '')} onChange={(e) => updateRow(i, 'key', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder="Value" value={String(r.value ?? '')} onChange={(e) => updateRow(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Schlüssel')} value={String(r.key ?? r.fieldKey ?? '')} onChange={(e) => updateRow(i, 'key', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Wert')} value={String(r.value ?? '')} onChange={(e) => updateRow(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeRow(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
@ -299,7 +300,7 @@ const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) =
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('index.5')}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
@ -316,17 +317,17 @@ const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">equals</option>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">contains</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</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' }} />
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
@ -366,18 +367,18 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" placeholder="Field" value={String(cond.field ?? '')} onChange={(e) => update('field', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Feld')} 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="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">contains</option>
<option value="contains">{t('enthält')}</option>
<option value="startsWith">{t('beginnt mit')}</option>
<option value="isEmpty">{t('ist leer')}</option>
<option value="isNotEmpty">{t('ist nicht leer')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</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' }} />
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);

View file

@ -99,7 +99,7 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>Datenquelle</label>
<label>{t('Datenquelle')}</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
</div>
<div className={styles.ifElseConditionRow}>
@ -114,7 +114,7 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
</div>
{needsValue && (
<div className={styles.ifElseConditionRow}>
<label>Wert</label>
<label>{t('Wert')}</label>
{mimeTypeOptions.length > 0 ? (
<select
value={String(value ?? '')}

View file

@ -45,15 +45,19 @@ function _buildPathsFromSchema(
return result;
}
function _buildPathsFromPreview(obj: unknown, basePath: (string | number)[] = []): PickablePath[] {
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : '(ganze Ausgabe)';
function _buildPathsFromPreview(
obj: unknown,
basePath: (string | number)[] = [],
wholeOutputLabel = '(ganze Ausgabe)',
): PickablePath[] {
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : wholeOutputLabel;
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
return [{ path: [...basePath], label: pathLabel }];
}
if (Array.isArray(obj)) {
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
for (let i = 0; i < Math.min(obj.length, 5); i++) {
result.push(..._buildPathsFromPreview(obj[i], [...basePath, i]));
result.push(..._buildPathsFromPreview(obj[i], [...basePath, i], wholeOutputLabel));
}
return result;
}
@ -61,7 +65,7 @@ function _buildPathsFromPreview(obj: unknown, basePath: (string | number)[] = []
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (k.startsWith('_')) continue;
result.push(..._buildPathsFromPreview(v, [...basePath, k]));
result.push(..._buildPathsFromPreview(v, [...basePath, k], wholeOutputLabel));
}
return result;
}
@ -160,7 +164,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => setShowSystem(!showSystem)}
>
<span className={styles.dataPickerExpandIcon}>{showSystem ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>System</span>
<span className={styles.dataPickerNodeLabel}>{t('System')}</span>
</button>
{showSystem && (
<div className={styles.dataPickerTree}>
@ -200,7 +204,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
const paths = schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId]);
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>

View file

@ -35,7 +35,8 @@ function buildLoopOptions(
sourceIds: string[],
nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>,
nodeOutputsPreview: Record<string, unknown>,
getNodeLabel: (n: { id: string; type?: string; title?: string }) => string
getNodeLabel: (n: { id: string; type?: string; title?: string }) => string,
translate: (key: string) => string
): LoopOption[] {
const options: LoopOption[] = [];
@ -50,13 +51,13 @@ function buildLoopOptions(
if (node?.type === 'trigger.form') {
options.push({
ref: createRef(nodeId, ['payload']),
label: `Alle Formularfelder (${nodeLabel})`,
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
});
const filesVal = getValueAtPath(preview, ['files']);
if (Array.isArray(filesVal)) {
options.push({
ref: createRef(nodeId, ['files']),
label: `Alle Dateien aus Formular (${nodeLabel})`,
label: `${translate('Alle Dateien aus Formular')} (${nodeLabel})`,
});
}
continue;
@ -65,7 +66,7 @@ function buildLoopOptions(
if (node?.type === 'input.form') {
options.push({
ref: createRef(nodeId, []),
label: `Alle Formularfelder (${nodeLabel})`,
label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
});
continue;
}
@ -73,11 +74,11 @@ function buildLoopOptions(
if (node?.type === 'input.upload') {
options.push({
ref: createRef(nodeId, ['files']),
label: `Alle hochgeladenen Dateien (${nodeLabel})`,
label: `${translate('Alle hochgeladenen Dateien')} (${nodeLabel})`,
});
options.push({
ref: createRef(nodeId, ['fileIds']),
label: `Alle Datei-IDs (${nodeLabel})`,
label: `${translate('Alle Datei-IDs')} (${nodeLabel})`,
});
continue;
}
@ -85,7 +86,7 @@ function buildLoopOptions(
if (node?.type === 'flow.loop') {
options.push({
ref: createRef(nodeId, ['items']),
label: `Alle Elemente aus Schleife (${nodeLabel})`,
label: `${translate('Alle Elemente aus Schleife')} (${nodeLabel})`,
});
continue;
}
@ -93,7 +94,7 @@ function buildLoopOptions(
if (node?.type === 'email.searchEmail') {
options.push({
ref: createRef(nodeId, ['data', 'searchResults', 'results']),
label: `Alle gefundenen E-Mails (${nodeLabel})`,
label: `${translate('Alle gefundenen E-Mails')} (${nodeLabel})`,
});
continue;
}
@ -101,7 +102,7 @@ function buildLoopOptions(
if (node?.type === 'email.checkEmail') {
options.push({
ref: createRef(nodeId, ['data', 'emails', 'emails']),
label: `Alle E-Mails (${nodeLabel})`,
label: `${translate('Alle E-Mails')} (${nodeLabel})`,
});
continue;
}
@ -109,7 +110,7 @@ function buildLoopOptions(
if (node?.type === 'sharepoint.listFiles') {
options.push({
ref: createRef(nodeId, ['files']),
label: `Alle Dateien (${nodeLabel})`,
label: `${translate('Alle Dateien')} (${nodeLabel})`,
});
continue;
}
@ -157,7 +158,7 @@ interface LoopItemsSelectProps {
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
onChange,
placeholder = 'Über was soll iteriert werden?',
placeholder,
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
@ -167,7 +168,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
if (sourceIds.length === 0) {
return (
<p className={styles.dynamicValueEmptyHint}>
Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.
{t('Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.')}
</p>
);
}
@ -176,7 +177,8 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
sourceIds,
dataFlow.nodes,
dataFlow.nodeOutputsPreview,
dataFlow.getNodeLabel
dataFlow.getNodeLabel,
t
);
const ref = isRef(value) ? value : null;
@ -198,7 +200,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
}}
className={styles.startsInput}
>
<option value="">{placeholder}</option>
<option value="">{placeholder ?? t('Über was soll iteriert werden?')}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
@ -206,7 +208,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
))}
</select>
<p className={styles.nodeConfigNameHint}>
Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
{t('Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.')}
</p>
</div>
);

View file

@ -6,6 +6,7 @@
import React from 'react';
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
/** How to build path options for StatischKontextSelect / RefSourceSelect. */
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
@ -131,6 +132,11 @@ export function refToOptionValue(ref: DataRef): string {
return JSON.stringify(ref);
}
function _pathLabelForDisplay(pathLabel: string, translate: (key: string) => string): string {
if (pathLabel === 'Aufgaben-ID') return translate('Aufgaben-ID');
return pathLabel;
}
export function optionValueToRef(s: string): DataRef | null {
try {
const o = JSON.parse(s) as unknown;
@ -190,10 +196,11 @@ interface StatischKontextSelectProps {
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
value,
onChange,
placeholder = '— Quelle wählen —',
staticLabel = 'Statisch',
placeholder,
staticLabel,
pathPickMode = 'default',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
@ -213,7 +220,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) {
const displayLabel = p.pathLabel ? `${nodeLabel}${p.pathLabel}` : nodeLabel;
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({
ref: createRef(nodeId, p.path),
label: displayLabel,
@ -245,8 +253,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
if (ref) onChange(ref);
}}
>
<option value="">{placeholder}</option>
<option value={STATIC_SOURCE_VALUE}>{staticLabel}</option>
<option value="">{placeholder ?? t('— Quelle wählen —')}</option>
<option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
@ -267,9 +275,10 @@ interface RefSourceSelectProps {
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
value,
onChange,
placeholder = 'Datenquelle wählen…',
placeholder,
pathPickMode = 'default',
}) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null;
@ -289,7 +298,8 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) {
const displayLabel = p.pathLabel ? `${nodeLabel}${p.pathLabel}` : nodeLabel;
const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({
ref: createRef(nodeId, p.path),
label: displayLabel,
@ -312,7 +322,7 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
if (ref) onChange(ref);
}}
>
<option value="">{placeholder}</option>
<option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
{options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label}
@ -343,13 +353,13 @@ function getFormFieldType(
if (!fieldName) return null;
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
if (!field || typeof field !== 'object') return null;
const t = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
if (t === 'number') return 'number';
if (t === 'email') return 'email';
if (t === 'date' || t === 'datetime') return 'date';
if (t === 'boolean' || t === 'checkbox') return 'boolean';
if (t === 'clickup_tasks') return 'string';
if (t === 'clickup_status') return 'string';
const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
if (rawFieldType === 'number') return 'number';
if (rawFieldType === 'email') return 'email';
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
if (rawFieldType === 'clickup_tasks') return 'string';
if (rawFieldType === 'clickup_status') return 'string';
return 'string';
}

View file

@ -25,7 +25,7 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
const o = f as Record<string, unknown>;
const fieldType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `Feld ${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type'];
@ -39,7 +39,7 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
}
return { name, label, type };
}
return { name: `field${i + 1}`, label: `Feld ${i + 1}`, type: 'text' as const };
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
});
}
@ -54,8 +54,9 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
return (
<div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}>
<strong>Formular-Felder</strong> werden beim Start ausgefüllt und liegen unter{' '}
<code>payload.&lt;name&gt;</code> in der Start-Ausgabe.
<strong>{t('Formular-Felder')}</strong>{' '}
{t('werden beim Start ausgefüllt und liegen unter')}{' '}
<code>payload.&lt;name&gt;</code> {t('in der Start-Ausgabe.')}
</p>
<div className={styles.formFieldsList}>
{fields.map((f, idx) => (
@ -72,7 +73,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/>
<input
className={styles.startsInput}
placeholder="Beschriftung"
placeholder={t('Beschriftung')}
value={f.label}
onChange={(e) => {
const next = [...fields];
@ -94,11 +95,11 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
setFields(next);
}}
>
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="email">E-Mail</option>
<option value="date">Datum</option>
<option value="boolean">Ja/Nein</option>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="email">{t('E-Mail')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Ja/Nein')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
</select>
<button
@ -117,7 +118,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }])
}
>
+ Feld
{t('+ Feld')}
</button>
</div>
</div>

View file

@ -209,8 +209,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
return (
<div className={styles.schedulePanel}>
<p className={styles.startNodeDocIntro}>
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird
unten automatisch erzeugt.
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
)}
</p>
<LayoutGroup>
@ -255,7 +256,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
<div className={styles.scheduleModeConfig}>
{o.value === 'daily' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
@ -268,7 +269,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'weekdays' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
@ -282,7 +283,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'weekly' && (
<>
<div className={styles.scheduleFieldCol}>
<span className={styles.scheduleFieldLabel}>Wochentage</span>
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
<div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => (
<button
@ -293,13 +294,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
}
onClick={() => toggleWeekday(cronDow)}
>
{label}
{t(label)}
</button>
))}
</div>
</div>
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
@ -323,7 +324,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
}
onClick={() => setCalendarPeriod('monthly')}
>
Monatlich
{t('Monatlich')}
</button>
<button
type="button"
@ -334,13 +335,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
}
onClick={() => setCalendarPeriod('yearly')}
>
Jährlich
{t('Jährlich')}
</button>
</div>
{spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Monatstag</span>
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
@ -358,7 +359,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{spec.calendarPeriod === 'yearly' && (
<div className={styles.scheduleYearlyRow}>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Monat</span>
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthIndex}
@ -366,13 +367,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
>
{MONTH_NAMES_DE.map((name, i) => (
<option key={i + 1} value={i + 1}>
{name}
{t(name)}
</option>
))}
</select>
</label>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Tag</span>
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
@ -389,7 +390,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
)}
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}

View file

@ -6,6 +6,7 @@
import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const SCHEMA_EXAMPLE = `{
"trigger": {
@ -22,17 +23,20 @@ const SCHEMA_EXAMPLE = `{
}`;
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
const { t } = useLanguage();
return (
<div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}>
Die <strong>Start</strong>-Node liefert beim Ausführen immer dieselbe Struktur. Den <strong>Einstiegstyp</strong>{' '}
(manuell, Formular, Zeitplan, ) wählen Sie über das <strong>Zahnrad</strong> oben in der
Workflow-Konfiguration.
{t(
'Die Start-Node liefert beim Ausführen immer dieselbe Struktur. Den Einstiegstyp (manuell, Formular, Zeitplan, …) wählen Sie über das Zahnrad oben in der Workflow-Konfiguration.'
)}
</p>
<p className={styles.startNodeDocSub}>
{t('Nachgelagerte Nodes können z. B. auf')}{' '}
<code>payload</code> {t('und')} <code>trigger.type</code> {t('zugreifen.')}
</p>
<p className={styles.startNodeDocSub}>Nachgelagerte Nodes können z.B. auf <code>payload</code> und{' '}
<code>trigger.type</code> zugreifen.</p>
<div className={styles.startNodeSchema}>
<div className={styles.startNodeSchemaTitle}>Ausgabe-Schema</div>
<div className={styles.startNodeSchemaTitle}>{t('Ausgabe-Schema')}</div>
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
</div>
</div>

View file

@ -117,7 +117,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
<option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label} ({o.value})
{t(o.label)} ({o.value})
</option>
))}
</select>
@ -166,7 +166,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
className={styles.startsInput}
value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? 'z.B. application/pdf' : `Wert`}
placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('Wert')}
/>
);
};
@ -185,7 +185,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
return (
<div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}>
<label>Datenquelle</label>
<label>{t('Datenquelle')}</label>
<RefSourceSelect
value={ref}
onChange={handleRefChange}
@ -221,7 +221,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
>
{operators.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</option>
))}
</select>
@ -241,7 +241,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
);
})}
<button type="button" className={styles.startsAddBtn} onClick={addCase}>
+ Fall
{t('+ Fall')}
</button>
</div>
</div>

View file

@ -162,13 +162,6 @@ const _SCOPE_ICONS: Record<string, string> = {
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Persönlich',
featureInstance: 'Instanz',
mandate: 'Mandant',
global: 'Global',
};
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
@ -187,6 +180,12 @@ interface SelectionCtx {
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
@ -257,14 +256,14 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
<span className={styles.rightZone}>
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{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={t('{count} Ordner löschen', { count: String(sel.selectedFolderIds.length) })}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
@ -300,7 +299,7 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
sel.onScopeChange(file.id, next);
}}
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
title={`${t('Scope')}: ${scopeLabels[file.scope!] || file.scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
@ -381,12 +380,12 @@ function _TreeNode({
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = await promptFolderName('Neuer Ordnername:', { title: t('Neuer Ordner'), placeholder: 'Ordnername' });
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
}
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]);
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName, t]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
@ -753,7 +752,7 @@ export default function FolderTree({
onDrop={_handleRootDrop}
>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>(Global)</span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>({t('Global')})</span>
<span className={styles.rootActions}>
{onRefresh && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onRefresh(); }} title={t('Aktualisieren')}>
@ -765,7 +764,7 @@ export default function FolderTree({
className={styles.actionBtn}
onClick={async (e) => {
e.stopPropagation();
const name = await promptFolderName('Neuer Ordnername:', { title: t('Neuer Ordner'), placeholder: 'Ordnername' });
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) await onCreateFolder(name.trim(), null);
}}
title={t('Neuer Ordner')}

View file

@ -7,6 +7,7 @@
import React, { useState, useCallback, useEffect } from 'react';
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface BrowseEntry {
name: string;
@ -100,6 +101,7 @@ function _FolderRow({
onSelectFolder?: (path: string) => void;
foldersOnly: boolean;
}) {
const { t } = useLanguage();
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
const children = loadedChildren[entry.path] ?? [];
@ -132,7 +134,7 @@ function _FolderRow({
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
onClick={handleChevronClick}
title={isExpanded ? 'Einklappen' : 'Erweitern'}
title={isExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
@ -148,7 +150,7 @@ function _FolderRow({
<div className={styles.children}>
{isLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
Wird geladen
{t('Wird geladen…')}
</div>
) : (
<>
@ -177,7 +179,7 @@ function _FolderRow({
))}
{children.length === 0 && (
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
Leer
{t('Leer')}
</div>
)}
</>
@ -199,6 +201,7 @@ export function SharepointBrowseTree({
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
const { t } = useLanguage();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
@ -261,11 +264,12 @@ export function SharepointBrowseTree({
<span
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
onClick={() => handleToggle(rootPath)}
title={isRootExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>SharePoint</span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>{t('SharePoint')}</span>
{rootLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
@ -274,7 +278,7 @@ export function SharepointBrowseTree({
<div className={styles.children}>
{rootLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
Sites werden geladen
{t('Sites werden geladen…')}
</div>
) : (
<>
@ -303,7 +307,7 @@ export function SharepointBrowseTree({
))}
{rootItems.length === 0 && !rootLoading && (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
Keine Einträge
{t('Keine Einträge')}
</div>
)}
</>

View file

@ -131,11 +131,11 @@ export function DeleteActionButton<T = any>({
} else {
// Refetch to restore the item in case of failure
await refetch();
onError?.(row, 'Delete failed');
onError?.(row, t('Löschen fehlgeschlagen'));
}
} catch (error: any) {
console.error('Delete failed:', error);
onError?.(row, error.message || 'Delete failed');
onError?.(row, error.message || t('Löschen fehlgeschlagen'));
// Refetch to restore the item in case of failure
await refetch();
} finally {

View file

@ -100,7 +100,7 @@ export function ViewActionButton<T = any>({
isOpen={isPopupOpen}
onClose={() => setIsPopupOpen(false)}
fileId={(row as any)[idField]}
fileName={(row as any)[nameField] || 'Unknown Item'}
fileName={(row as any)[nameField] || t('Unbekanntes Element')}
mimeType={mimeType}
/>
)}
@ -115,7 +115,7 @@ export function ViewActionButton<T = any>({
>
<div style={{ padding: '20px' }}>
<h3 style={{ marginBottom: '20px', fontSize: '1.2rem', fontWeight: 'bold' }}>
{(row as any)[nameField] || (row as any)[idField] || 'Details'}
{(row as any)[nameField] || (row as any)[idField] || t('Details')}
</h3>
<div style={{ display: 'grid', gap: '15px' }}>
{Object.entries(row as Record<string, any>)

View file

@ -20,7 +20,7 @@ const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
return false;
}
return 'en' in value && typeof value.en === 'string';
return 'xx' in value && typeof value.xx === 'string';
};
// Note: Field types are determined ONLY by the explicit 'type' property.
@ -50,7 +50,7 @@ export interface AttributeDefinition {
export interface AttributeOption {
value: string | number;
label: string | { [language: string]: string };
label: string;
}
// FormGeneratorForm props
@ -243,7 +243,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
if (!isTextMultilingual(processedData[attr.name])) {
// If it's a string, convert to TextMultilingual
if (typeof processedData[attr.name] === 'string') {
processedData[attr.name] = { en: processedData[attr.name] };
processedData[attr.name] = { xx: processedData[attr.name] };
}
}
}
@ -268,8 +268,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
if (attr.default !== undefined) {
initialData[attr.name] = attr.default;
} else if (isMultilingual(attr)) {
// Initialize TextMultilingual fields with empty object
initialData[attr.name] = { en: '' };
initialData[attr.name] = { xx: '' };
} else {
initialData[attr.name] = getDefaultValueForType(attr.type);
}
@ -327,11 +326,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, label: String(opt) };
}
// Handle multilingual labels
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
return { value: opt.value, label: opt.label || String(opt.value) };
});
}
@ -436,12 +431,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
return { value: opt, label: opt };
}
if (typeof opt === 'object' && 'value' in opt) {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
label: opt.label || String(opt.value)
};
}
return { value: String(opt), label: String(opt) };
@ -464,9 +456,9 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Special handling for TextMultilingual fields (by explicit type only)
const isMultilingualField = isMultilingualType(attr.type as AttributeType);
if (isMultilingualField && isTextMultilingual(value)) {
if (!value.en || typeof value.en !== 'string' || value.en.trim() === '') {
if (!value.xx || typeof value.xx !== 'string' || value.xx.trim() === '') {
newErrors[attr.name] = t('{fieldLabel} ist erforderlich', {
fieldLabel: `${attr.label} (Englisch)`,
fieldLabel: attr.label,
});
return;
}
@ -655,21 +647,17 @@ export function FormGeneratorForm<T extends Record<string, any>>({
}
};
// Build multilingual language list dynamically from availableLanguages.
// 'en' is always first and required; remaining languages follow in DB order.
// xx = source/default text (required), then all available languages dynamically.
const multilingualLangs = useMemo(() => {
const base: { code: string; uiLabel: string; required: boolean }[] = [
{ code: 'en', uiLabel: 'EN', required: true },
const langs: { code: string; uiLabel: string; required: boolean }[] = [
{ code: 'xx', uiLabel: t('Quelltext'), required: true },
];
for (const lang of availableLanguages) {
if (lang.code === 'en' || lang.code === 'xx') continue;
base.push({ code: lang.code, uiLabel: lang.code.toUpperCase(), required: false });
if (lang.code === 'xx') continue;
langs.push({ code: lang.code, uiLabel: lang.code.toUpperCase(), required: false });
}
if (base.length === 1) {
base.push({ code: 'de', uiLabel: 'DE', required: false });
}
return base;
}, [availableLanguages]);
return langs;
}, [availableLanguages, t]);
const _handleAutoTranslate = async (attrName: string, multilingualValue: Record<string, string>) => {
const sourceLang = multilingualLangs.find(l => (multilingualValue[l.code] || '').trim())?.code;
@ -702,11 +690,11 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Render multilingual field
const renderMultilingualField = (attr: AttributeDefinition) => {
const value = formData[attr.name] || { en: '' };
const value = formData[attr.name] || { xx: '' };
const hasError = errors[attr.name];
const isReadonly = mode === 'display' || attr.readonly || !isFieldEditableInMode(attr, mode);
const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' };
const multilingualValue = isTextMultilingual(value) ? value : { xx: typeof value === 'string' ? value : '' };
const handleMultilingualChange = (langCode: string, langValue: string) => {
const newValue = { ...multilingualValue, [langCode]: langValue };
@ -745,7 +733,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
onChange={(e) => handleMultilingualChange(lang.code, e.target.value)}
onFocus={() => handleFieldFocus(`${attr.name}.${lang.code}`, true)}
onBlur={() => handleFieldFocus(`${attr.name}.${lang.code}`, false)}
className={`${styles.fieldInput} ${hasError && lang.code === 'en' ? styles.fieldError : ''}`}
className={`${styles.fieldInput} ${hasError && lang.code === 'xx' ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(`${attr.name}.${lang.code}`, multilingualValue[lang.code])}>
{lang.uiLabel}

View file

@ -40,12 +40,6 @@ const CHART_COLORS = [
'#bab0ac'
];
const MONTH_LABELS: Record<string, string> = {
'01': 'Jan', '02': 'Feb', '03': 'Mär', '04': 'Apr',
'05': 'Mai', '06': 'Jun', '07': 'Jul', '08': 'Aug',
'09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Dez'
};
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
@ -54,15 +48,22 @@ function _defaultFormatCurrency(value: number, currencyCode: string): string {
return `${currencyCode} ${value.toFixed(2)}`;
}
function _formatDateLabel(dateStr: string): string {
function _createFormatDateLabel(t: (key: string) => string): (dateStr: string) => string {
const monthLabels: Record<string, string> = {
'01': t('Jan'), '02': t('Feb'), '03': t('Mär'), '04': t('Apr'),
'05': t('Mai'), '06': t('Jun'), '07': t('Jul'), '08': t('Aug'),
'09': t('Sep'), '10': t('Okt'), '11': t('Nov'), '12': t('Dez'),
};
return (dateStr: string) => {
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parseInt(parts[2], 10)}.`;
}
if (parts.length === 2) {
return MONTH_LABELS[parts[1]] || parts[1];
return monthLabels[parts[1]] || parts[1];
}
return dateStr;
};
}
// =============================================================================
@ -74,12 +75,13 @@ interface CustomTooltipProps {
payload?: any[];
label?: string;
formatValue?: (value: number) => string;
formatDateLabel: (dateStr: string) => string;
}
const _CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, formatValue }) => {
const _CustomTooltip: React.FC<CustomTooltipProps> = ({ active, payload, label, formatValue, formatDateLabel }) => {
if (!active || !payload?.length) return null;
const displayLabel = label ? _formatDateLabel(String(label)) : '';
const displayLabel = label ? formatDateLabel(String(label)) : '';
return (
<div className={styles.customTooltip}>
@ -115,15 +117,18 @@ const _renderKpiGrid = (section: ReportSectionKpi): React.ReactNode => {
// --- Bar Chart (vertical) ---
const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
const _renderBarChart = (
section: ReportSectionBarChart,
currencyCode: string,
formatDateLabel: (dateStr: string) => string,
t: (key: string) => string,
): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>{t('Keine Daten')}</div>;
}
const chartData = section.data.map(d => ({
name: _formatDateLabel(d.key),
name: formatDateLabel(d.key),
value: d.value,
rawKey: d.key
}));
@ -146,12 +151,12 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
tickFormatter={(v) => formatter(v)}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
<Tooltip content={<_CustomTooltip formatValue={formatter} formatDateLabel={formatDateLabel} />} />
<Bar
dataKey="value"
fill={section.color || CHART_COLORS[0]}
radius={[4, 4, 0, 0]}
name="Wert"
name={t('Wert')}
/>
</BarChart>
</ResponsiveContainer>
@ -161,9 +166,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
// --- Horizontal Bar Chart ---
const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode: string, t: (key: string) => string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>{t('Keine Daten')}</div>;
}
@ -194,9 +197,12 @@ const _renderHorizontalBar = (section: ReportSectionHorizontalBar, currencyCode:
// --- Line Chart ---
const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
const _renderLineChart = (
section: ReportSectionLineChart,
currencyCode: string,
formatDateLabel: (dateStr: string) => string,
t: (key: string) => string,
): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>{t('Keine Daten')}</div>;
}
@ -212,7 +218,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
dataKey="date"
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={_formatDateLabel}
tickFormatter={formatDateLabel}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
@ -220,7 +226,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
tickFormatter={formatter}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
<Tooltip content={<_CustomTooltip formatValue={formatter} formatDateLabel={formatDateLabel} />} />
{section.series.map((s, i) => (
<Line
key={s.key}
@ -242,9 +248,12 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
// --- Area Chart ---
const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
const _renderAreaChart = (
section: ReportSectionAreaChart,
currencyCode: string,
formatDateLabel: (dateStr: string) => string,
t: (key: string) => string,
): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>{t('Keine Daten')}</div>;
}
@ -260,7 +269,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
dataKey="date"
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
axisLine={{ stroke: 'var(--border-color, #333)' }}
tickFormatter={_formatDateLabel}
tickFormatter={formatDateLabel}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--text-secondary, #888)' }}
@ -268,7 +277,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
tickFormatter={formatter}
width={70}
/>
<Tooltip content={<_CustomTooltip formatValue={formatter} />} />
<Tooltip content={<_CustomTooltip formatValue={formatter} formatDateLabel={formatDateLabel} />} />
{section.series.map((s, i) => (
<Area
key={s.key}
@ -290,9 +299,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
// --- Pie Chart ---
const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string): React.ReactNode => {
const { t } = useLanguage();
const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string, t: (key: string) => string): React.ReactNode => {
if (!section.data?.length) {
return <div className={styles.noData}>{t('Keine Daten')}</div>;
}
@ -418,7 +425,7 @@ const _ReportTableSection: React.FC<ReportTableSectionProps> = ({ section, curre
{hasMore && !showAll && (
<div className={styles.showMoreRow}>
<button className={styles.showMoreButton} onClick={() => setShowAll(true)}>
Alle {section.rows.length} Einträge anzeigen
{t('Alle {count} Einträge anzeigen', { count: String(section.rows.length) })}
</button>
</div>
)}
@ -436,6 +443,9 @@ interface SectionWrapperProps {
}
const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode }) => {
const { t } = useLanguage();
const formatDateLabel = useMemo(() => _createFormatDateLabel(t), [t]);
const spanClass = section.type === 'kpiGrid' || section.span === 'full'
? styles.sectionFull
: section.span === 'half'
@ -452,20 +462,18 @@ const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode
);
}
const _renderContent = (): React.ReactNode => {
const { t } = useLanguage();
const renderContent = (): React.ReactNode => {
switch (section.type) {
case 'barChart':
return _renderBarChart(section, currencyCode);
return _renderBarChart(section, currencyCode, formatDateLabel, t);
case 'horizontalBar':
return _renderHorizontalBar(section, currencyCode);
return _renderHorizontalBar(section, currencyCode, t);
case 'lineChart':
return _renderLineChart(section, currencyCode);
return _renderLineChart(section, currencyCode, formatDateLabel, t);
case 'areaChart':
return _renderAreaChart(section, currencyCode);
return _renderAreaChart(section, currencyCode, formatDateLabel, t);
case 'pieChart':
return _renderPieChart(section, currencyCode);
return _renderPieChart(section, currencyCode, t);
case 'table':
return <_ReportTableSection section={section} currencyCode={currencyCode} />;
default:
@ -477,7 +485,7 @@ const _SectionWrapper: React.FC<SectionWrapperProps> = ({ section, currencyCode
<div className={`${spanClass} ${styles.sectionCard}`}>
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
{section.description && <p className={styles.sectionDescription}>{section.description}</p>}
{_renderContent()}
{renderContent()}
</div>
);
};
@ -607,7 +615,7 @@ const _Toolbar: React.FC<ToolbarProps> = ({
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
/>
<span className={styles.toolbarLabel}>Bis</span>
<span className={styles.toolbarLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.dateInput}
@ -640,7 +648,7 @@ const _Toolbar: React.FC<ToolbarProps> = ({
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
>
<option value="">{filter.placeholder || 'Alle'}</option>
<option value="">{filter.placeholder || t('Alle')}</option>
{filter.options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}

View file

@ -96,7 +96,7 @@ export const UserSection: React.FC = () => {
onClick={handleBilling}
>
<span className={styles.menuIcon}>💰</span>
Guthaben
{t('Guthaben')}
</button>
<button
@ -104,7 +104,7 @@ export const UserSection: React.FC = () => {
onClick={handleSettings}
>
<span className={styles.menuIcon}></span>
Einstellungen
{t('Einstellungen')}
</button>
{onboardingHidden && (
@ -113,7 +113,7 @@ export const UserSection: React.FC = () => {
onClick={handleOnboarding}
>
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
Onboarding-Assistent
{t('Onboarding-Assistent')}
</button>
)}
@ -122,7 +122,7 @@ export const UserSection: React.FC = () => {
onClick={handleLegal}
>
<span className={styles.menuIcon}>📜</span>
Rechtliche Hinweise
{t('Rechtliche Hinweise')}
</button>
<div className={styles.menuDivider} />
@ -133,7 +133,7 @@ export const UserSection: React.FC = () => {
disabled={isLoggingOut}
>
<span className={styles.menuIcon}>🚪</span>
{isLoggingOut ? 'Abmelden...' : 'Abmelden'}
{isLoggingOut ? t('Abmelden...') : t('Abmelden')}
</button>
</div>
)}
@ -176,13 +176,13 @@ export const UserSection: React.FC = () => {
<div className={styles.legalLinks}>
<a href="/poweron-privacy.html" target="_blank" rel="noopener noreferrer">
Datenschutzrichtlinie
{t('Datenschutzrichtlinie')}
</a>
<a href="/poweron-terms.html" target="_blank" rel="noopener noreferrer">
Nutzungsbedingungen
{t('Nutzungsbedingungen')}
</a>
<a href="/poweron-home.html" target="_blank" rel="noopener noreferrer">
Über PowerOn
{t('Über PowerOn')}
</a>
</div>
</div>

View file

@ -20,32 +20,32 @@ const typeIcons: Record<string, React.ReactNode> = {
mention: <FaExclamationTriangle />
};
// Format timestamp to relative time (Unix seconds)
function formatRelativeTime(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '';
}
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return 'Gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('de-DE');
}
interface NotificationBellProps {
className?: string;
}
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
const { t } = useLanguage();
const formatRelativeTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '';
}
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return t('Gerade eben');
if (diff < 3600) return t('vor {minutes} Min.', { minutes: String(Math.floor(diff / 60)) });
if (diff < 86400) return t('vor {hours} Std.', { hours: String(Math.floor(diff / 3600)) });
if (diff < 604800) return t('vor {days} Tagen', { days: String(Math.floor(diff / 86400)) });
const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('de-DE');
}, [t]);
const {
notifications,
unreadCount,
@ -144,7 +144,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
<button
className={styles.bellButton}
onClick={() => setIsOpen(!isOpen)}
aria-label={`Benachrichtigungen ${unreadCount > 0 ? `(${unreadCount} ungelesen)` : ''}`}
aria-label={unreadCount > 0 ? t('Benachrichtigungen ({count} ungelesen)', { count: String(unreadCount) }) : t('Benachrichtigungen')}
>
<FaBell className={styles.bellIcon} />
{unreadCount > 0 && (
@ -165,7 +165,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
className={styles.markAllRead}
onClick={() => markAllAsRead()}
>
Alle als gelesen markieren
{t('Alle als gelesen markieren')}
</button>
)}
</div>
@ -201,7 +201,7 @@ export const NotificationBell: React.FC<NotificationBellProps> = ({ className })
{actionSuccess === notification.id && (
<div className={styles.successOverlay}>
<FaCheckCircle />
<span>{notification.actionResult || 'Erfolgreich'}</span>
<span>{notification.actionResult || t('Erfolgreich')}</span>
</div>
)}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api';
import OnboardingWizard from './OnboardingWizard';
@ -19,13 +19,6 @@ interface OnboardingAssistantProps {
const _STORAGE_KEY = 'onboarding_hidden';
const _CALLOUTS: Record<string, string> = {
mandate: 'Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.',
connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.',
chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.',
};
export function _isOnboardingHidden(): boolean {
try {
return localStorage.getItem(_STORAGE_KEY) === 'true';
@ -48,6 +41,12 @@ function _hideOnboarding(): void {
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
const { t } = useLanguage();
const callouts = useMemo(() => ({
mandate: t('Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.'),
feature: t('Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.'),
connection: t('Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.'),
chat: t('Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.'),
}), [t]);
const navigate = useNavigate();
const location = useLocation();
const [hidden, setHidden] = useState(() => _isOnboardingHidden());
@ -99,7 +98,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
id: 'mandate',
label: t('Mandat einrichten'),
description: hasAdminMandate
? 'Dein Mandant ist eingerichtet.'
? t('Dein Mandant ist eingerichtet.')
: hasFeature
? t('Du bist Mitglied eines Mandanten')
: t('Erstelle deinen Arbeitsbereich'),
@ -166,7 +165,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
} finally {
setLoading(false);
}
}, [navigate]);
}, [navigate, t]);
useEffect(() => {
const state = location.state as { showOnboarding?: number } | null;
@ -215,7 +214,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>{t('Willkommen bei Poweron')}</h3>
<p style={{ margin: '4px 0 0', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{completedCount} von {steps.length} Schritten abgeschlossen
{t('{completed} von {total} Schritten abgeschlossen', { completed: String(completedCount), total: String(steps.length) })}
</p>
</div>
</div>
@ -263,14 +262,14 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span>
)}
</div>
{isNextStep && _CALLOUTS[step.id] && (
{isNextStep && callouts[step.id as keyof typeof callouts] && (
<div style={{
marginTop: 4, marginLeft: 34, padding: '6px 10px',
fontSize: '0.78rem', color: 'var(--accent, #4f46e5)',
background: 'rgba(79, 70, 229, 0.06)', borderRadius: 6,
borderLeft: '3px solid var(--accent, #4f46e5)',
}}>
{_CALLOUTS[step.id]}
{callouts[step.id as keyof typeof callouts]}
</div>
)}
</div>
@ -290,7 +289,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
onChange={(e) => setDontShowAgain(e.target.checked)}
style={{ margin: 0 }}
/>
Nicht wieder anzeigen
{t('Nicht wieder anzeigen')}
</label>
<button
onClick={_handleDismiss}
@ -304,7 +303,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
borderRadius: 6,
}}
>
Schliessen
{t('Schliessen')}
</button>
</div>
</div>

View file

@ -30,7 +30,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
window.dispatchEvent(new CustomEvent('features-changed'));
onComplete();
} catch (err: any) {
setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung');
setError(err?.response?.data?.detail || t('Fehler bei der Einrichtung'));
} finally {
setLoading(false);
}
@ -48,7 +48,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
}}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>{t('Mandant erstellen')}</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
{t('Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.')}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
@ -62,7 +62,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<div>
<strong>{t('Kostenlos testen')}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
7 Tage gratis, danach flexibel upgraden
{t('7 Tage gratis, danach flexibel upgraden')}
</div>
</div>
</label>
@ -77,7 +77,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<div>
<strong>{t('Standard monatlich')}</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Team-Workspace mit vollem Funktionsumfang
{t('Team-Workspace mit vollem Funktionsumfang')}
</div>
</div>
</label>
@ -85,7 +85,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span>
{t('Name des Mandanten')} <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>({t('optional')})</span>
</label>
<input
type="text" value={mandateName}
@ -106,7 +106,7 @@ const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismi
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer',
}}>
Abbrechen
{t('Abbrechen')}
</button>
<button onClick={_handleSubmit} disabled={loading}
style={{

View file

@ -77,8 +77,7 @@ export function _toBackendProviders(
return _resolveProviders(selection, allowedProviders);
}
// Provider display names
const PROVIDER_LABELS: Record<string, string> = {
const _PROVIDER_LABEL_KEYS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
mistral: 'Mistral (Le Chat)',
@ -88,6 +87,11 @@ const PROVIDER_LABELS: Record<string, string> = {
internal: 'Internal',
};
function _providerLabel(provider: string, t: (key: string) => string): string {
const key = _PROVIDER_LABEL_KEYS[provider];
return key ? t(key) : provider;
}
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
@ -115,10 +119,11 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({ value,
onChange,
disabled = false,
className,
label = 'AI-Provider',
label,
showLabel = true,
}) => {
const { t } = useLanguage();
const resolvedLabel = label ?? t('AI-Provider');
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
@ -130,13 +135,13 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({ value,
const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({
value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${_providerLabel(provider, t)}`,
}));
}, [allowedProviders]);
}, [allowedProviders, t]);
return (
<div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
{showLabel && <label className={styles.label}>{resolvedLabel}</label>}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
@ -174,12 +179,13 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
onChange,
disabled = false,
className,
label = 'AI-Provider',
label,
showLabel = true,
defaultExpanded = false,
excludeByDefault = [],
}) => {
const { t } = useLanguage();
const resolvedLabel = label ?? t('AI-Provider');
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -264,13 +270,13 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
}, [effectiveSelection, noneSelected]);
const summaryHint = useMemo(() => {
if (noneSelected) return 'Kein Provider ausgewählt';
if (allSelected) return 'Alle Provider aktiv (dynamisch)';
if (noneSelected) return t('Kein Provider ausgewählt');
if (allSelected) return t('Alle Provider aktiv (dynamisch)');
if (selection.include.includes(PROVIDER_ALL)) {
return `Alle ausser ${selection.exclude.length} Provider`;
return t('Alle ausser {count} Provider', { count: String(selection.exclude.length) });
}
return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
return t('{n} von {total} Provider', { n: String(effectiveSelection.length), total: String(allowedProviders.length) });
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders, t]);
return (
<div
@ -289,7 +295,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
{isExpanded && (
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
{showLabel && <div className={styles.dropdownHeader}>{resolvedLabel}</div>}
<div className={styles.selectActions}>
<button
@ -298,7 +304,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
disabled={disabled}
className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
>
Alle
{t('Alle')}
</button>
</div>
@ -319,7 +325,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
<span className={styles.providerName}>
{PROVIDER_LABELS[provider] || provider}
{_providerLabel(provider, t)}
</span>
</label>
))}
@ -355,7 +361,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
<span key={provider} className={styles.badge}>
{PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider}
{PROVIDER_ICONS[provider] || '🔌'} {_providerLabel(provider, t)}
</span>
))}
</div>

View file

@ -80,20 +80,20 @@ const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
return (
<div className={styles.preview}>
<div className={styles.previewHeader}>
<h4 className={styles.previewTitle}>Export-Vorschau</h4>
<h4 className={styles.previewTitle}>{t('Export-Vorschau')}</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.previewContent}>
<div className={styles.previewSection}>
<h5>Scope</h5>
<h5>{t('Scope')}</h5>
<ul className={styles.previewList}>
<li><strong>Typ:</strong> {data.scope.type}</li>
<li><strong>{t('Typ:')}</strong> {data.scope.type}</li>
{data.scope.mandateName && <li><strong>{t('Mandant')}</strong> {data.scope.mandateName}</li>}
{data.scope.featureCode && <li><strong>Feature:</strong> {data.scope.featureCode}</li>}
{data.scope.featureCode && <li><strong>{t('Feature:')}</strong> {data.scope.featureCode}</li>}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Rollen ({data.roles.length})</h5>
<h5>{t('Rollen ({count})', { count: String(data.roles.length) })}</h5>
<ul className={styles.previewList}>
{data.roles.slice(0, 5).map((role, i) => (
<li key={i}>
@ -102,21 +102,21 @@ const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
</li>
))}
{data.roles.length > 5 && (
<li className={styles.moreItems}>... und {data.roles.length - 5} weitere</li>
<li className={styles.moreItems}>{t('... und {count} weitere', { count: String(data.roles.length - 5) })}</li>
)}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Regeln ({data.accessRules.length})</h5>
<h5>{t('Regeln ({count})', { count: String(data.accessRules.length) })}</h5>
<ul className={styles.previewList}>
{data.accessRules.slice(0, 5).map((rule, i) => (
<li key={i}>
<span className={styles.contextBadge}>{rule.context}</span>
<code>{rule.item || '(global)'}</code>
<code>{rule.item || t('(global)')}</code>
</li>
))}
{data.accessRules.length > 5 && (
<li className={styles.moreItems}>... und {data.accessRules.length - 5} weitere</li>
<li className={styles.moreItems}>{t('... und {count} weitere', { count: String(data.accessRules.length - 5) })}</li>
)}
</ul>
</div>
@ -154,7 +154,7 @@ const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
</div>
<div className={styles.resultContent}>
<ul className={styles.resultStats}>
<li><strong>Modus:</strong> {importModes.find(m => m.value === result.mode)?.label}</li>
<li><strong>{t('Modus:')}</strong> {importModes.find(m => m.value === result.mode)?.label}</li>
<li><strong>{t('Rollen erstellt')}</strong> {result.rolesCreated}</li>
<li><strong>{t('Rollen aktualisiert')}</strong> {result.rolesUpdated}</li>
<li><strong>{t('Regeln erstellt')}</strong> {result.rulesCreated}</li>
@ -238,7 +238,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
if (result.success && result.data) {
setImportData(result.data);
} else {
setParseError(result.error || 'Fehler beim Parsen');
setParseError(result.error || t('Fehler beim Parsen'));
setImportData(null);
}
};
@ -289,13 +289,14 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileExport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Export</h3>
<h3 className={styles.sectionTitle}>{t('Export')}</h3>
</div>
<div className={styles.sectionContent}>
<p className={styles.sectionDescription}>
Exportiert alle Rollen und Berechtigungen
{isGlobal ? ' der globalen Templates' : ` des Mandanten "${mandateName || mandateId}"`}
{featureCode ? ` für Feature "${featureCode}"` : ''} als JSON-Datei.
{t('Exportiert alle Rollen und Berechtigungen')}{' '}
{isGlobal ? t('der globalen Templates') : t('des Mandanten "{name}"', { name: String(mandateName || mandateId || '') })}
{featureCode ? <> {t('für Feature "{code}"', { code: featureCode })}</> : null}{' '}
{t('als JSON-Datei.')}
</p>
<button
className={styles.primaryButton}
@ -304,11 +305,11 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
>
{exporting ? (
<>
<FaSpinner className="spinning" /> Exportieren...
<FaSpinner className="spinning" /> {t('Exportieren...')}
</>
) : (
<>
<FaDownload /> RBAC exportieren
<FaDownload /> {t('RBAC exportieren')}
</>
)}
</button>
@ -319,7 +320,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileImport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Import</h3>
<h3 className={styles.sectionTitle}>{t('Import')}</h3>
</div>
<div className={styles.sectionContent}>
{/* File Upload */}
@ -368,14 +369,14 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
<div className={styles.importInfo}>
<div className={styles.importStats}>
<span><strong>{t('Rollen')}</strong> {importData.roles.length}</span>
<span><strong>Regeln:</strong> {importData.accessRules.length}</span>
<span><strong>Quelle:</strong> {importData.scope.type}</span>
<span><strong>{t('Regeln:')}</strong> {importData.accessRules.length}</span>
<span><strong>{t('Quelle:')}</strong> {importData.scope.type}</span>
</div>
<button
className={styles.previewButton}
onClick={() => setShowPreview(true)}
>
<FaEye /> Vorschau
<FaEye /> {t('Vorschau')}
</button>
</div>
)}
@ -383,7 +384,7 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
{/* Import Mode Selection */}
{importData && (
<div className={styles.importModeSection}>
<h4 className={styles.importModeTitle}>Import-Modus</h4>
<h4 className={styles.importModeTitle}>{t('Import-Modus')}</h4>
<div className={styles.importModes}>
{importModes.map(mode => (
<label
@ -416,11 +417,11 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
>
{importing ? (
<>
<FaSpinner className="spinning" /> Importieren...
<FaSpinner className="spinning" /> {t('Importieren...')}
</>
) : (
<>
<FaUpload /> RBAC importieren
<FaUpload /> {t('RBAC importieren')}
</>
)}
</button>
@ -430,7 +431,8 @@ export const RbacExportImport: React.FC<RbacExportImportProps> = ({
{importMode === 'replace' && importData && (
<div className={styles.warningMessage}>
<FaExclamationTriangle />
<strong>Achtung:</strong> Im Modus "Ersetzen" werden alle bestehenden Rollen und Regeln gelöscht!
<strong>{t('Achtung:')}</strong>{' '}
{t('Im Modus Ersetzen werden alle bestehenden Rollen und Regeln gelöscht!')}
</div>
)}
</div>

View file

@ -95,7 +95,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
setAutocompleteError(null); // Clear any previous errors on success
} catch (err: any) {
console.error('❌ [AddressAutocomplete] Error in performSearch:', err);
const errorMessage = err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Adressvorschläge';
const errorMessage = err?.response?.data?.detail || err?.message || t('Fehler beim Laden der Adressvorschläge');
setAutocompleteError(errorMessage);
setSuggestions([]);
setShowSuggestions(true); // Show dropdown to display error
@ -103,7 +103,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
} finally {
setIsLoading(false);
}
}, [minQueryLength, maxSuggestions]);
}, [minQueryLength, maxSuggestions, t]);
// Handle input change with debouncing
const handleInputChange = useCallback((newValue: string) => {
@ -286,7 +286,7 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({ value = '',
)}
{!isLoading && !autocompleteError && suggestions.length === 0 && query.length >= minQueryLength && (
<li className={styles.suggestionItem}>
<span className={styles.noResultsText}>{t('no addresses found')}</span>
<span className={styles.noResultsText}>{t('Keine Adressen gefunden')}</span>
</li>
)}
{!isLoading && suggestions.map((suggestion, index) => (

View file

@ -30,7 +30,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
<div className={styles.bauvorschriftenHeader} onClick={() => setIsExpanded(!isExpanded)}>
<h4 className={styles.subSectionTitle}>
<FaRuler style={{ marginRight: '8px', display: 'inline' }} />
Bauvorschriften - {bauvorschriften.zonenbezeichnung}
{t('Bauvorschriften')} {bauvorschriften.zonenbezeichnung}
</h4>
<button className={styles.expandButton}>
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
@ -48,7 +48,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
)}
{bauvorschriften.vollgeschosse !== undefined && bauvorschriften.vollgeschosse !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Vollgeschosse:</span>
<span className={styles.label}>{t('Vollgeschosse')}</span>
<span className={styles.value}>{bauvorschriften.vollgeschosse}</span>
</div>
)}
@ -60,7 +60,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
)}
{bauvorschriften.grenzabstand !== undefined && bauvorschriften.grenzabstand !== null && (
<div className={styles.bauvorschriftItem}>
<span className={styles.label}>Grenzabstand:</span>
<span className={styles.label}>{t('Grenzabstand')}</span>
<span className={styles.value}>{bauvorschriften.grenzabstand} m</span>
</div>
)}
@ -93,7 +93,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
className={styles.sourceLinkButton}
>
<FaFilePdf style={{ marginRight: '8px' }} />
Nutzungsplan öffnen
{t('Nutzungsplan öffnen')}
</a>
</div>
)}
@ -101,7 +101,7 @@ export const BauvorschriftenSection: React.FC<BauvorschriftenSectionProps> = ({
{bauvorschriften.extraktionsDatum && (
<div className={styles.bauvorschriftenFooter}>
<span className={styles.lastUpdated}>
Extrahiert: {new Date(bauvorschriften.extraktionsDatum).toLocaleString('de-CH')}
{t('Extrahiert')}: {new Date(bauvorschriften.extraktionsDatum).toLocaleString('de-CH')}
</span>
</div>
)}

View file

@ -8,7 +8,7 @@ import { useLanguage } from '../../../../providers/language/LanguageContext';
const CreateButton: React.FC<CreateButtonProps> = ({
onCreate,
fields,
popupTitle = 'Create New Item',
popupTitle = 'Neues Element erstellen',
popupSize = 'medium',
disabled = false,
loading = false,
@ -131,7 +131,7 @@ const CreateButton: React.FC<CreateButtonProps> = ({
} catch (error: any) {
console.error('Creation failed:', error);
if (onError) {
onError(error.message || 'Creation failed');
onError(error.message || t('Erstellung fehlgeschlagen'));
}
} finally {
setIsCreating(false);

View file

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import { UploadButtonProps } from '../ButtonTypes';
import Button from '../Button';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const UploadButton: React.FC<UploadButtonProps> = ({
onUpload,
@ -71,7 +72,7 @@ const UploadButton: React.FC<UploadButtonProps> = ({
{isUploading && (
<div className="spinnerIcon" style={{ marginRight: '8px' }} />
)}
{children || (isUploading ? 'Uploading...' : 'Upload File')}
{children || (isUploading ? t('Wird hochgeladen…') : t('Datei hochladen'))}
</Button>
<input

View file

@ -67,7 +67,7 @@ export function ConnectedFilesList({
previewingFiles = new Set(),
removingFiles = new Set(),
workflowId: _workflowId,
emptyMessage = 'No files connected to this workflow'
emptyMessage = 'Keine Dateien mit diesem Workflow verbunden'
}: ConnectedFilesListProps) {
const { t } = useLanguage();
// Combine workflow files and pending files, deduplicating by fileId
@ -253,7 +253,7 @@ export function ConnectedFilesList({
)}
{isPendingFile && (
<span style={{ fontSize: '0.75rem', color: '#4CAF50', fontWeight: 500 }}>
Attached
{t('Angehängt')}
</span>
)}
</div>

View file

@ -3,6 +3,7 @@ import { IconType } from 'react-icons';
import { IoChevronDown, IoClose } from 'react-icons/io5';
import styles from './DropdownSelect.module.css';
import { ButtonVariant, ButtonSize } from '../Button/ButtonTypes';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface DropdownSelectItem<T = any> {
id: string | number;
@ -37,8 +38,8 @@ function DropdownSelect<T = any>({
items = [],
selectedItemId,
onSelect,
placeholder = 'Select an item',
emptyMessage = 'No items available',
placeholder,
emptyMessage,
headerText,
variant = 'primary',
size = 'md',
@ -54,6 +55,9 @@ function DropdownSelect<T = any>({
showClearButton = true,
clearButtonLabel
}: DropdownSelectProps<T>) {
const { t } = useLanguage();
const resolvedPlaceholder = placeholder ?? t('Element auswählen');
const resolvedEmptyMessage = emptyMessage ?? t('Keine Einträge verfügbar');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -116,7 +120,7 @@ function DropdownSelect<T = any>({
return (
<>
<div className={styles.buttonSpinner} />
<span>{placeholder}</span>
<span>{resolvedPlaceholder}</span>
</>
);
}
@ -134,7 +138,7 @@ function DropdownSelect<T = any>({
return (
<>
{Icon && <Icon className={styles.buttonIcon} />}
<span className={styles.buttonText}>{placeholder}</span>
<span className={styles.buttonText}>{resolvedPlaceholder}</span>
<IoChevronDown className={`${styles.chevronIcon} ${isOpen ? styles.chevronOpen : ''}`} />
</>
);
@ -153,7 +157,7 @@ function DropdownSelect<T = any>({
className={buttonClasses}
onClick={handleClear}
disabled={disabled || loading}
title={clearButtonLabel || `Clear selection: ${selectedItem.label}`}
title={clearButtonLabel || t('Auswahl aufheben: {label}', { label: selectedItem.label })}
>
{Icon && <Icon className={styles.buttonIcon} />}
<span className={styles.buttonText}>
@ -197,7 +201,7 @@ function DropdownSelect<T = any>({
{items.length === 0 ? (
<div className={styles.dropdownEmpty}>
{emptyMessage}
{resolvedEmptyMessage}
</div>
) : (
<div className={styles.dropdownItems}>

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Button, TextField } from '../index';
import { FaLocationArrow } from 'react-icons/fa';
import styles from './LocationInput.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface LocationInputProps {
value: string;
@ -20,12 +21,15 @@ const LocationInput: React.FC<LocationInputProps> = ({
onChange,
onUseCurrentLocation,
isGettingLocation = false,
placeholder = 'Kanton, Gemeinde, Adresse oder Parzelle',
label = 'Standort',
placeholder,
label,
error,
helperText,
disabled = false
}) => {
const { t } = useLanguage();
const resolvedPlaceholder = placeholder ?? t('Kanton, Gemeinde, Adresse oder Parzelle');
const resolvedLabel = label ?? t('Standort');
const [isRequestingLocation, setIsRequestingLocation] = useState(false);
const handleUseCurrentLocation = async () => {
@ -62,7 +66,7 @@ const LocationInput: React.FC<LocationInputProps> = ({
loading={isGettingLocation || isRequestingLocation}
className={styles.locationButton}
>
Meine Position verwenden
{t('Meine Position verwenden')}
</Button>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { LogProps } from './LogTypes';
import { AutoScroll } from '../AutoScroll';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from './Log.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Helper to get status badge class
const getStatusBadgeClass = (status?: string | null): string => {
@ -22,11 +23,13 @@ const getStatusBadgeClass = (status?: string | null): string => {
const Log: React.FC<LogProps> = ({
className = '',
emptyMessage = 'No log information available',
emptyMessage = 'Keine Log-Informationen verfügbar',
dashboardTree,
onToggleOperationExpanded,
getChildOperations
}) => {
const { t } = useLanguage();
const resolvedEmptyMessage = typeof emptyMessage === 'string' ? t(emptyMessage, emptyMessage) : emptyMessage;
const formatLogTimestamp = (timestamp: number): string => {
try {
const formatted = formatUnixTimestamp(timestamp, undefined, {
@ -87,7 +90,7 @@ const Log: React.FC<LogProps> = ({
}
// Use stable operation name (from first log) or fallback to operationId
const operationName = operation.operationName || `Operation ${operationId}`;
const operationName = operation.operationName || `${t('Operation')} ${operationId}`;
// Use latest message as status tag (updates with each poll)
const latestMessage = operation.latestMessage || '';
const operationStatus = operation.latestStatus || 'running';
@ -137,7 +140,7 @@ const Log: React.FC<LogProps> = ({
<button
className={styles.expandButton}
onClick={() => onToggleOperationExpanded?.(operationId)}
aria-label={operation.expanded ? 'Collapse' : 'Expand'}
aria-label={operation.expanded ? t('Einklappen') : t('Ausklappen')}
>
<span className={`${styles.collapseIcon} ${operation.expanded ? '' : styles.collapsed}`}>
@ -243,7 +246,7 @@ const Log: React.FC<LogProps> = ({
if (dashboardTree.rootOperations.length === 0) {
return (
<div className={styles.emptyState}>{emptyMessage}</div>
<div className={styles.emptyState}>{resolvedEmptyMessage}</div>
);
}
@ -260,7 +263,7 @@ const Log: React.FC<LogProps> = ({
if (!hasDashboardLogs) {
return (
<div className={`${styles.logContainer} ${className}`}>
<div className={styles.emptyState}>{emptyMessage}</div>
<div className={styles.emptyState}>{resolvedEmptyMessage}</div>
</div>
);
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { Message } from '../../Messages/MessagesTypes';
import { DocumentItem, MessageMetadata, ActionInfo } from '../../Messages/MessageParts';
import { WorkflowFile } from '../../../../hooks/usePlayground';
@ -41,6 +42,7 @@ export const LogMessage: React.FC<LogMessageProps> = ({
downloadingFiles,
workflowId
}) => {
const { t } = useLanguage();
return (
<div className={`${logStyles.logMessage} ${styles.messageWrapper}`}>
{/* Metadata row */}
@ -58,7 +60,7 @@ export const LogMessage: React.FC<LogMessageProps> = ({
{message.summary && message.summary !== message.message && (
<div className={logStyles.logSummary}>
<strong>Summary:</strong> {message.summary}
<strong>{t('Zusammenfassung')}:</strong> {message.summary}
</div>
)}

View file

@ -223,9 +223,9 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({ parcels = [],
const polygon = L.polygon(latLngs, SELECTED_STYLE);
polygon.bindPopup(`
<div>
<strong>Parzelle ${parcel.number || parcel.id}</strong><br/>
<strong>${t('Parzelle')} ${parcel.number || parcel.id}</strong><br/>
${parcel.egrid ? `EGRID: ${parcel.egrid}<br/>` : ''}
<em>{t('Ausgewählt')}</em>
<em>${t('Ausgewählt')}</em>
</div>
`);
if (onParcelClick) {
@ -235,7 +235,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({ parcels = [],
layersRef.current.push(polygon);
});
}
}, [parcels, combinedOutline, onParcelClick]);
}, [parcels, combinedOutline, onParcelClick, t]);
// Handle map clicks
useEffect(() => {
@ -361,7 +361,7 @@ const MapViewLeaflet: React.FC<MapViewProps> = ({ parcels = [],
<div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />
{parcels.length === 0 && !center && (
<div className={styles.emptyStateOverlay}>
<p>{emptyMessage}</p>
<p>{typeof emptyMessage === 'string' ? t(emptyMessage, emptyMessage) : emptyMessage}</p>
</div>
)}
{showWfsParcels && isWfsLoading && (

View file

@ -150,7 +150,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({ message,
{/* Summary if different from message */}
{message.summary && message.summary !== message.message && (
<div className={styles.messageSummary}>
<strong>Summary:</strong> {message.summary}
<strong>{t('Zusammenfassung')}:</strong> {message.summary}
</div>
)}

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { MessagesProps } from './MessagesTypes';
import { ChatMessage } from './ChatMessages/ChatMessage';
import { LogMessage } from '../Log/LogMessage/LogMessage';
@ -15,7 +16,7 @@ const Messages: React.FC<MessagesProps> = ({
showProgress = true,
renderMessage,
renderDocument,
emptyMessage = 'No messages yet',
emptyMessage = 'Noch keine Nachrichten',
onFileDelete,
onFileRemove,
onFileView,
@ -28,10 +29,12 @@ const Messages: React.FC<MessagesProps> = ({
onMessageDelete,
deletingMessages
}) => {
const { t } = useLanguage();
const resolvedEmptyMessage = typeof emptyMessage === 'string' ? t(emptyMessage, emptyMessage) : emptyMessage;
if (!messages || messages.length === 0) {
return (
<div className={`${styles.messagesContainer} ${styles.emptyContainer} ${className}`}>
<div className={styles.emptyState}>{emptyMessage}</div>
<div className={styles.emptyState}>{resolvedEmptyMessage}</div>
</div>
);
}

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { FaChevronDown, FaChevronUp, FaFilePdf, FaInfoCircle } from 'react-icons/fa';
import { UrlContentPreview } from '../../ContentPreview';
import styles from './OerebSection.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface OerebData {
extract_url?: string;
@ -21,6 +22,7 @@ export interface OerebSectionProps {
}
export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
const { t } = useLanguage();
const [isExpanded, setIsExpanded] = useState(true);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const restrictions = oereb.restrictions || [];
@ -34,7 +36,7 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
<div className={styles.oerebHeader} onClick={() => setIsExpanded(!isExpanded)}>
<h4 className={styles.subSectionTitle}>
<FaInfoCircle style={{ marginRight: '8px', display: 'inline' }} />
ÖREB-Kataster
{t('ÖREB-Kataster')}
{restrictions.length > 0 && (
<span className={styles.badge}>({restrictions.length})</span>
)}
@ -54,7 +56,7 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
type="button"
>
<FaFilePdf style={{ marginRight: '8px' }} />
Vollständigen ÖREB-Auszug öffnen (PDF)
{t('Vollständigen ÖREB-Auszug öffnen (PDF)')}
</button>
</div>
)}
@ -68,14 +70,14 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
{restriction.law_status && (
<span className={styles.restrictionStatus}>
{restriction.law_status === 'inKraft' || restriction.law_status === 'inForce'
? 'In Kraft'
? t('In Kraft')
: restriction.law_status}
</span>
)}
</div>
{restriction.type && (
<div className={styles.restrictionType}>
<span className={styles.label}>Typ:</span>
<span className={styles.label}>{t('Typ:')}</span>
<span className={styles.value}>{restriction.type}</span>
</div>
)}
@ -86,7 +88,7 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
)}
{restriction.documents && restriction.documents.length > 0 && (
<div className={styles.restrictionDocuments}>
<span className={styles.label}>Dokumente:</span>
<span className={styles.label}>{t('Dokumente:')}</span>
{restriction.documents.map((doc, docIndex) => (
<a
key={docIndex}
@ -95,7 +97,7 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
rel="noopener noreferrer"
className={styles.documentLink}
>
Dokument {docIndex + 1}
{t('Dokument {nr}', { nr: String(docIndex + 1) })}
</a>
))}
</div>
@ -105,14 +107,14 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
</div>
) : (
<div className={styles.noRestrictions}>
Keine öffentlich-rechtlichen Beschränkungen gefunden.
{t('Keine öffentlich-rechtlichen Beschränkungen gefunden.')}
</div>
)}
{oereb.last_updated && (
<div className={styles.oerebFooter}>
<span className={styles.lastUpdated}>
Aktualisiert: {new Date(oereb.last_updated).toLocaleString('de-CH')}
{t('Aktualisiert')}: {new Date(oereb.last_updated).toLocaleString('de-CH')}
</span>
</div>
)}
@ -124,7 +126,7 @@ export const OerebSection: React.FC<OerebSectionProps> = ({ oereb }) => {
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
url={oereb.extract_url}
fileName="ÖREB-Auszug.pdf"
fileName={t('ÖREB-Auszug.pdf')}
mimeType="application/pdf"
/>
)}

View file

@ -147,12 +147,12 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
} catch (e: any) {
setDocsError(prev => ({
...prev,
[parcelId]: e?.response?.data?.detail || e?.message || 'Fehler beim Laden'
[parcelId]: e?.response?.data?.detail || e?.message || t('Fehler beim Laden')
}));
} finally {
setDocsLoading(prev => ({ ...prev, [parcelId]: false }));
}
}, [instanceId]);
}, [instanceId, t]);
const runExtraction = useCallback(async (
parcelId: string,
@ -174,12 +174,12 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
} catch (e: any) {
setExtractError(prev => ({
...prev,
[parcelId]: e?.response?.data?.detail || e?.message || 'Fehler bei der Extraktion'
[parcelId]: e?.response?.data?.detail || e?.message || t('Fehler bei der Extraktion')
}));
} finally {
setExtractLoading(prev => ({ ...prev, [parcelId]: false }));
}
}, [instanceId]);
}, [instanceId, t]);
useEffect(() => {
if (!isOpen || !instanceId) return;
@ -218,7 +218,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
className={styles.panel}
>
<div className={styles.header}>
<h2 className={styles.title}>Parzellen-Informationen ({parcels.length})</h2>
<h2 className={styles.title}>{t('Parzellen-Informationen')} ({parcels.length})</h2>
<button className={styles.closeButton} onClick={onClose}>
<FaTimes />
</button>
@ -248,7 +248,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
return [
<section key={`h-${bz.bauzone}`} className={styles.bauzoneSection}>
<h4 className={styles.bauzoneTitle}>
Bauzone {bz.bauzone}
{t('Bauzone')} {bz.bauzone}
{bz.area_m2 != null && (
<span className={styles.bauzoneArea}> {bz.area_m2.toFixed(2)} m²</span>
)}
@ -260,7 +260,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
<section key={parcelData.parcel.id} className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>
Parzelle {idx + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
{t('Parzelle')} {idx + 1}: {parcelData.parcel.number || parcelData.parcel.id || t('Unbekannt')}
</h3>
{onRemoveParcel && (
<button
@ -342,7 +342,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
)}
{instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && (
<div className={styles.bzoSection}>
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
<h4 className={styles.subSectionTitle}>{t('Bauzonenverordnung')}</h4>
{docsLoading[parcelData.parcel.id] && (
<p className={styles.bzoHint}>{t('Dokumente werden geladen')}</p>
)}
@ -364,7 +364,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
})}
title={t('Dokument öffnen')}
>
<FaEye /> Öffnen
<FaEye /> {t('Öffnen')}
</button>
</div>
))}
@ -387,7 +387,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
) : (
<FaFileAlt />
)}
Inhalt extrahieren (LangGraph)
{t('Inhalt extrahieren (LangGraph)')}
</button>
)}
{extractError[parcelData.parcel.id] && (
@ -470,7 +470,9 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
<div className={styles.infoItem}>
<span className={styles.label}>Zone:</span>
<span className={styles.value}>
{parcelData.parcel.zone.length} Zone{parcelData.parcel.zone.length !== 1 ? 'n' : ''} gefunden
{parcelData.parcel.zone.length !== 1
? t('{n} Zonen gefunden', { n: String(parcelData.parcel.zone.length) })
: t('1 Zone gefunden')}
{(() => {
// Extract zone types from zone array
const zoneTypes = parcelData.parcel.zone
@ -509,7 +511,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
rel="noopener noreferrer"
className={styles.link}
>
Link öffnen
{t('Link öffnen')}
</a>
</div>
)}
@ -526,7 +528,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
<section key={parcelData.parcel.id || index} className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>
Parzelle {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || 'Unbekannt'}
{t('Parzelle')} {index + 1}: {parcelData.parcel.number || parcelData.parcel.id || t('Unbekannt')}
</h3>
{onRemoveParcel && (
<button
@ -608,7 +610,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
)}
{instanceId && parcelData.parcel.municipality_name && parcelData.parcel.bauzone && (docsLoading[parcelData.parcel.id] || (parcelDocs[parcelData.parcel.id] || []).length > 0) && (
<div className={styles.bzoSection}>
<h4 className={styles.subSectionTitle}>Bauzonenverordnung</h4>
<h4 className={styles.subSectionTitle}>{t('Bauzonenverordnung')}</h4>
{docsLoading[parcelData.parcel.id] && (
<p className={styles.bzoHint}>{t('Dokumente werden geladen')}</p>
)}
@ -630,7 +632,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
})}
title={t('Dokument öffnen')}
>
<FaEye /> Öffnen
<FaEye /> {t('Öffnen')}
</button>
</div>
))}
@ -653,7 +655,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
) : (
<FaFileAlt />
)}
Inhalt extrahieren (LangGraph)
{t('Inhalt extrahieren (LangGraph)')}
</button>
)}
{extractError[parcelData.parcel.id] && (
@ -736,7 +738,9 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
<div className={styles.infoItem}>
<span className={styles.label}>Zone:</span>
<span className={styles.value}>
{parcelData.parcel.zone.length} Zone{parcelData.parcel.zone.length !== 1 ? 'n' : ''} gefunden
{parcelData.parcel.zone.length !== 1
? t('{n} Zonen gefunden', { n: String(parcelData.parcel.zone.length) })
: t('1 Zone gefunden')}
{(() => {
const zoneTypes = parcelData.parcel.zone
.map((z: any) => {
@ -773,7 +777,7 @@ const ParcelInfoPanel: React.FC<ParcelInfoPanelProps> = ({ isOpen,
rel="noopener noreferrer"
className={styles.link}
>
Link öffnen
{t('Link öffnen')}
</a>
</div>
)}

View file

@ -46,6 +46,17 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
latestStats
}) => {
const { t } = useLanguage();
const statusLabel = (status: WorkflowStatusType | null): string => {
if (!status) return '';
const map: Record<string, string> = {
completed: t('Abgeschlossen'),
failed: t('Fehlgeschlagen'),
started: t('Gestartet'),
stopped: t('Gestoppt'),
resumed: t('Fortgesetzt'),
};
return map[status] ?? status;
};
// Use workflow status and round from API response, fallback to extracting from logs
const workflowStatus = useMemo(() => {
if (workflowStatusFromApi) {
@ -73,11 +84,11 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
)}
{workflowStatus.status && (
<span className={styles.statusBadge} data-status={workflowStatus.status}>
{workflowStatus.status.charAt(0).toUpperCase() + workflowStatus.status.slice(1)}
{statusLabel(workflowStatus.status)}
</span>
)}
{workflowStatus.round !== null && (
<span className={styles.roundBadge}>Round {workflowStatus.round}</span>
<span className={styles.roundBadge}>{t('Runde {nr}', { nr: String(workflowStatus.round) })}</span>
)}
</div>
@ -85,7 +96,7 @@ const WorkflowStatus: React.FC<WorkflowStatusProps> = ({
{latestStats && latestStats.priceCHF !== undefined && (
<div className={styles.statsContainer}>
<div className={styles.statItem}>
<span className={styles.statLabel}>Cost:</span>
<span className={styles.statLabel}>{t('Kosten')}</span>
<span className={styles.statValue}>{_formatCurrency(latestStats.priceCHF)}</span>
</div>
</div>

View file

@ -103,7 +103,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
}
groupMap.get(fiId)!.chats.push({
id: wf.id,
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
label: wf.label || wf.name || `${t('Chat')} ${wf.id.slice(0, 8)}`,
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
lastMessageAt: wf.lastMessageAt,
featureInstanceId: fiId,
@ -132,7 +132,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
} finally {
setLoading(false);
}
}, [context.instanceId]);
}, [context.instanceId, t]);
useEffect(() => { _loadChats(); }, [_loadChats]);
@ -283,7 +283,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _startEditing(chat); }}
title="Umbenennen"
title={t('Umbenennen')}
>
</button>
@ -292,7 +292,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title="Wiederherstellen"
title={t('Wiederherstellen')}
>
</button>
@ -300,7 +300,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title="Archivieren"
title={t('Archivieren')}
>
📦
</button>
@ -323,10 +323,10 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
const _featureCodeLabel = (code: string): string => {
const labels: Record<string, string> = {
workspace: 'AI Workspace',
commcoach: 'CommCoach',
trustee: 'Trustee',
automation: 'Automation',
workspace: t('KI-Arbeitsbereich'),
commcoach: t('CommCoach'),
trustee: t('Trustee'),
automation: t('Automation'),
};
return labels[code] || code;
};
@ -351,7 +351,7 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? 'Baumansicht' : 'Listenansicht'}
title={flatMode ? t('Baumansicht') : t('Listenansicht')}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
@ -362,13 +362,13 @@ const ChatsTab: React.FC<ChatsTabProps> = ({ context,
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
Aktiv ({_activeCount})
{t('Aktiv')} ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
Archiv ({_archivedCount})
{t('Archiv')} ({_archivedCount})
</button>
</div>

View file

@ -250,12 +250,12 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
}}>
Dateien hier ablegen
{t('Dateien hier ablegen')}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>{t('Dateien')}</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
@ -331,10 +331,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} Persönlich</span>
<span>{'\uD83D\uDC65'} Instanz</span>
<span>{'\uD83C\uDFE2'} Mandant</span>
<span>{'\uD83D\uDD12'} Neutralisiert</span>
<span>{'\uD83D\uDC64'} {t('Persönlich')}</span>
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>
</div>
</div>
);

View file

@ -85,7 +85,7 @@ interface MandateGroupNode {
interface FeatureTableNode {
objectKey: string;
tableName: string;
label: Record<string, string>;
label: string;
fields: string[];
isParent?: boolean;
parentTable?: string;
@ -185,13 +185,6 @@ const _SCOPE_ICONS: Record<string, string> = {
global: '\uD83C\uDF10',
};
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Personal',
featureInstance: 'Feature Instance',
mandate: 'Mandate',
global: 'Global',
};
function _nextScope(current: string): string {
const idx = _SCOPE_ORDER.indexOf(current);
if (idx === -1) return _SCOPE_ORDER[0];
@ -348,6 +341,14 @@ function _Spinner(): React.ReactElement {
const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) => {
const { t } = useLanguage();
const _scopeLabel = (scope: string) => ({
personal: t('Persönlich'),
featureInstance: t('Feature-Instanz'),
mandate: t('Mandant'),
global: t('Global'),
} as Record<string, string>)[scope] || scope;
const _scopeCycleTitle = (scope: string) =>
`${t('Bereich')}: ${_scopeLabel(scope)}${_scopeLabel(_nextScope(scope))}`;
const instanceId = context.instanceId;
/* ── Active sources (fetched internally) ── */
@ -663,7 +664,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
featureCode: node.featureCode,
tableName: table.tableName,
objectKey: table.objectKey,
label: table.label?.en || table.label?.de || table.tableName,
label: table.label || table.tableName,
});
_fetchFeatureDataSources();
onSourcesChanged?.();
@ -764,7 +765,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName);
if (parentTable) {
const parentLabel = `${parentTable.label?.en || parentTable.label?.de || parentTable.tableName}: ${parentRecord.displayLabel}`;
const parentLabel = `${parentTable.label || parentTable.tableName}: ${parentRecord.displayLabel}`;
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
@ -776,7 +777,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
}
for (const child of childTables) {
const childLabel = `${child.label?.en || child.label?.de || child.tableName}: ${parentRecord.displayLabel}`;
const childLabel = `${child.label || child.tableName}: ${parentRecord.displayLabel}`;
await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
@ -813,7 +814,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Personal Sources
{t('Aktive persönliche Quellen')}
</div>
{[...dataSources].sort((a, b) => {
const aKey = `${a.sourceType}|${a.label || a.path || ''}`;
@ -842,7 +843,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
}}
title={`Scope: ${_SCOPE_LABELS[ds.scope] || ds.scope}${_SCOPE_LABELS[_nextScope(ds.scope)]}`}
title={_scopeCycleTitle(ds.scope)}
>
{_SCOPE_ICONS[ds.scope] || _SCOPE_ICONS.personal}
</button>
@ -874,7 +875,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{/* ── Browse Sources header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources
{t('Quellen durchsuchen')}
</span>
<button
onClick={_loadConnections}
@ -888,13 +889,13 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{/* ── Browse Sources tree ── */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections...
{t('Verbindungen werden geladen…')}
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No active connections found.
{t('Keine aktiven Verbindungen.')}
</div>
)}
@ -917,7 +918,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{featureDataSources.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
Active Feature Sources
{t('Aktive Feature-Quellen')}
</div>
{(() => {
const sorted = [...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || ''));
@ -979,7 +980,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
<button
onClick={() => _cycleFeatureScope(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }}
title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope}${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
title={_scopeCycleTitle(fds.scope)}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
@ -1022,7 +1023,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
<button
onClick={() => _cycleFeatureScope(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1 }}
title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope}${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
title={_scopeCycleTitle(fds.scope)}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
@ -1053,7 +1054,7 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{/* ── Feature Data header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Feature Data
{t('Feature-Daten')}
</span>
<button
onClick={_loadFeatureConnections}
@ -1067,13 +1068,13 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged }) =>
{/* ── Feature Data tree ── */}
{loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading feature instances...
{t('Feature-Instanzen werden geladen…')}
</div>
)}
{!loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
No feature instances found.
{t('Keine Feature-Instanzen gefunden.')}
</div>
)}
@ -1169,7 +1170,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
}}
title={t('Als Datenquelle hinzufügen')}
>
{isAdding ? '...' : '+ Add'}
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{canAdd && alreadyAdded && (
@ -1197,7 +1198,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
(empty)
{t('(leer)')}
</div>
)}
</div>
@ -1299,6 +1300,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
expandedParentGroups, loadingParentGroup, addingParentKey,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = node.expanded ? '\u25BE' : '\u25B8';
@ -1329,7 +1331,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{node.label}
</span>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} tables
{node.tableCount} {t('Tabellen')}
</span>
</div>
@ -1342,7 +1344,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
const isGroupLoading = loadingParentGroup === groupKey;
const records = node.parentRecords[pt.tableName];
const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName);
const ptLabel = pt.label?.en || pt.label?.de || pt.tableName;
const ptLabel = pt.label || pt.tableName;
return (
<_ParentGroupView
@ -1380,7 +1382,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
(no tables)
{t('(keine Tabellen)')}
</div>
)}
</div>
@ -1402,7 +1404,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const tableLabel = table.label?.en || table.label?.de || table.tableName;
const tableLabel = table.label || table.tableName;
return (
<div
@ -1433,7 +1435,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
}}
title={t('Als Feature-Datenquelle hinzufügen')}
>
{isAdding ? '...' : '+ Add'}
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{isAdded && (
@ -1467,6 +1469,7 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables,
onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey,
}) => {
const { t } = useLanguage();
const [hovered, setHovered] = useState(false);
const chevron = expanded ? '\u25BE' : '\u25B8';
@ -1493,7 +1496,7 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
</span>
{childTables.length > 0 && (
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
+{childTables.length} tables
+{childTables.length} {t('Tabellen')}
</span>
)}
</div>
@ -1518,7 +1521,7 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
{expanded && records && records.length === 0 && !loading && (
<div style={{ paddingLeft: 52, fontSize: 11, color: '#bbb', padding: '2px 0 2px 52px' }}>
(no records)
{t('(keine Einträge)')}
</div>
)}
</div>
@ -1580,7 +1583,7 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
}}
title={t('Alle Tabellen für diese Quelle hinzufügen')}
>
{isAdding ? '...' : '+ Add'}
{isAdding ? '...' : `+ ${t('Hinzufügen')}`}
</button>
)}
{isAdded && (
@ -1593,7 +1596,7 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
{record.expanded && (
<div style={{ paddingLeft: 64 }}>
{childTables.map(ct => {
const ctLabel = ct.label?.en || ct.label?.de || ct.tableName;
const ctLabel = ct.label || ct.tableName;
return (
<div key={ct.objectKey} style={{
display: 'flex', alignItems: 'center', gap: 4,

View file

@ -31,7 +31,7 @@ interface UnifiedDataBarProps {
}
const _TAB_KEYS: Record<UdbTab, string> = {
chats: 'Chats',
chats: 'Chatverläufe',
files: 'Dateien',
sources: 'Quellen',
};

View file

@ -83,8 +83,6 @@ 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);
}
@ -460,7 +458,7 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
setSidebarItems(items);
} catch (err) {
console.error('❌ SidebarProvider: Error refreshing sidebar:', err);
setError(err instanceof Error ? err.message : 'Failed to load sidebar items');
setError(err instanceof Error ? err.message : t('Seitenleiste konnte nicht geladen werden'));
} finally {
setLoading(false);
}

View file

@ -34,16 +34,11 @@ export interface GenericPageData {
type TranslationFunction = (key: string, fallback?: string) => string;
/**
* Resolve display text from a page name that may be a i18n key or { de?, en? }.
* Resolve display text from a page name (i18n key) via the translation function.
*/
export function resolveLanguageText(
name: string | { de?: string; en?: string },
name: string,
t: TranslationFunction
): string {
if (typeof name === 'string') {
const resolved = t(name);
return resolved !== name ? resolved : name;
}
const lang = (typeof navigator !== 'undefined' && navigator.language?.startsWith('en')) ? 'en' : 'de';
return name[lang] ?? name.de ?? name.en ?? '';
return t(name);
}

View file

@ -7,6 +7,7 @@
import { useState, useCallback } from 'react';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
// =============================================================================
// TYPES
@ -78,6 +79,7 @@ interface SaveResult {
// =============================================================================
export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac', mandateId?: string) {
const { t } = useLanguage();
const [rules, setRules] = useState<AccessRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -113,14 +115,14 @@ export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac'
setRules(fetchedRules);
return fetchedRules;
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
const errorMsg = err.response?.data?.detail || err.message || t('Fehler beim Laden der Regeln');
setError(errorMsg);
console.error('Error fetching rules:', err);
return [];
} finally {
setLoading(false);
}
}, [roleId, apiBasePath, isInstanceApi, getHeaders]);
}, [roleId, apiBasePath, isInstanceApi, getHeaders, t]);
/**
* Save all rules for the role
@ -196,14 +198,14 @@ export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac'
return { success: true };
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
const errorMsg = err.response?.data?.detail || err.message || t('Fehler beim Speichern');
setError(errorMsg);
console.error('Error saving rules:', err);
return { success: false, error: errorMsg };
} finally {
setSaving(false);
}
}, [roleId, apiBasePath, isInstanceApi, fetchRules, getHeaders]);
}, [roleId, apiBasePath, isInstanceApi, fetchRules, getHeaders, t]);
/**
* Get rules grouped by context

View file

@ -188,15 +188,10 @@ export function useMandates() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -206,15 +201,10 @@ export function useMandates() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -327,15 +317,10 @@ export function useMandates() {
fieldType = 'enum';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
@ -343,15 +328,10 @@ export function useMandates() {
fieldType = 'multiselect';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}

View file

@ -229,15 +229,10 @@ export function useRbacRoles() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -247,15 +242,10 @@ export function useRbacRoles() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -368,15 +358,10 @@ export function useRbacRoles() {
fieldType = 'enum';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
@ -384,15 +369,10 @@ export function useRbacRoles() {
fieldType = 'multiselect';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}

View file

@ -205,15 +205,10 @@ export function useRbacRules() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -223,15 +218,10 @@ export function useRbacRules() {
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
@ -344,15 +334,10 @@ export function useRbacRules() {
fieldType = 'enum';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
@ -360,15 +345,10 @@ export function useRbacRules() {
fieldType = 'multiselect';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attrOptions.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}

View file

@ -26,7 +26,7 @@ export interface PaginationMetadata {
export interface Feature {
code: string;
label: string | { [key: string]: string };
label: string;
icon?: string;
enabled?: boolean;
}
@ -62,7 +62,7 @@ export interface FeatureAccessUser {
export interface FeatureInstanceRole {
id: string;
roleLabel: string;
description?: { [key: string]: string };
description?: string;
featureCode?: string;
isSystemRole?: boolean;
}
@ -312,7 +312,7 @@ export function useFeatureAccess() {
name: string;
features: Array<{
code: string;
label: string | { [key: string]: string };
label: string;
instances: Array<{
id: string;
featureCode: string;

View file

@ -12,7 +12,7 @@ import api from '../api';
export interface Role {
id: string;
roleLabel: string;
description?: string | { [key: string]: string };
description?: string;
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
@ -24,7 +24,7 @@ export interface Role {
export interface RoleCreate {
roleLabel: string;
description?: string | { [key: string]: string };
description?: string;
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
@ -32,7 +32,7 @@ export interface RoleCreate {
export interface RoleUpdate {
roleLabel?: string;
description?: string | { [key: string]: string };
description?: string;
mandateId?: string | null;
}

View file

@ -33,7 +33,7 @@ export interface AttributeDefinition {
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
options?: Array<{ value: string | number; label: string }> | string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;

View file

@ -20,7 +20,7 @@ export type { Prompt, AttributeDefinition, PaginationParams };
// Re-export AttributeOption for backward compatibility
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string };
label: string;
}
// Prompts list hook
@ -185,15 +185,10 @@ export function usePrompts() {
fieldType = 'enum';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: String(opt.label ?? opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
@ -201,15 +196,10 @@ export function usePrompts() {
fieldType = 'multiselect';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: String(opt.label ?? opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -25,7 +25,7 @@ export interface RbacExportScope {
export interface RbacExportRole {
roleLabel: string;
description?: { [key: string]: string };
description?: string;
featureCode?: string;
}

View file

@ -181,7 +181,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
label: opt.label || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'multiselect') {
@ -189,7 +189,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
label: opt.label || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'textarea') fieldType = 'textarea';
@ -224,7 +224,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
label: opt.label || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'multiselect') {
@ -232,7 +232,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
if (Array.isArray(attr.options)) {
options = (attr.options as any[]).map((opt: any) => ({
value: opt.value,
label: typeof opt.label === 'string' ? opt.label : opt.label?.en || String(opt.value),
label: opt.label || String(opt.value),
}));
} else if (typeof attr.options === 'string') optionsReference = attr.options;
} else if (attr.type === 'textarea') fieldType = 'textarea';

View file

@ -237,24 +237,18 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
options = attr.options.map((opt: any) => ({
value: opt.value, label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
options = attr.options.map((opt: any) => ({
value: opt.value, label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
@ -303,24 +297,18 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
options = attr.options.map((opt: any) => ({
value: opt.value, label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return { value: opt.value, label: labelValue };
});
options = attr.options.map((opt: any) => ({
value: opt.value, label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -193,15 +193,10 @@ export function useTrusteeAccess() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -193,15 +193,10 @@ export function useTrusteeContracts() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -194,15 +194,10 @@ export function useTrusteeDocuments() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -191,30 +191,20 @@ export function useTrusteeOrganisations() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -181,15 +181,10 @@ export function useTrusteePositionDocuments() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -207,15 +207,10 @@ export function useTrusteePositions() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -201,15 +201,10 @@ export function useTrusteeRoles() {
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map((opt: any) => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -51,7 +51,7 @@ export interface UserMandateResponse {
export interface Role {
id: string;
roleLabel: string;
description?: string | { [key: string]: string };
description?: string;
mandateId?: string;
featureInstanceId?: string;
featureCode?: string;
@ -60,7 +60,7 @@ export interface Role {
export interface Mandate {
id: string;
name: string | { [key: string]: string };
name: string;
label?: string;
code?: string;
language?: string;

View file

@ -253,7 +253,7 @@ export function useCurrentUser() {
// Re-export AttributeOption for backward compatibility
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string };
label: string;
}
// Organization users hook (list, update, delete) - following prompts/workflows pattern
@ -436,15 +436,10 @@ export function useOrgUsers() {
fieldType = 'enum';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attr.options;
@ -453,15 +448,10 @@ export function useOrgUsers() {
fieldType = 'multiselect';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attr.options;
@ -582,12 +572,9 @@ export function useOrgUsers() {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
label: opt.label || String(opt.value)
};
});
} else if (typeof attr.options === 'string') {
@ -596,15 +583,10 @@ export function useOrgUsers() {
} else if (attrType === 'multiselect') {
fieldType = 'multiselect';
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -93,7 +93,7 @@ export type { AttributeDefinition } from '../api/attributesApi';
// Attribute option interface (from backend)
export interface AttributeOption {
value: string | number;
label: string | { [key: string]: string }; // Can be string or object with language keys
label: string;
}
// Pagination parameters
@ -300,15 +300,10 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
fieldType = 'enum';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}
@ -316,15 +311,10 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
fieldType = 'multiselect';
// Handle options - can be array or string reference
if (Array.isArray(attr.options)) {
options = attr.options.map(opt => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label)[0]] || String(opt.value);
return {
options = attr.options.map(opt => ({
value: opt.value,
label: labelValue
};
});
label: opt.label || String(opt.value)
}));
} else if (typeof attr.options === 'string') {
optionsReference = attr.options;
}

View file

@ -5,10 +5,11 @@
* Stellt den Instanz-Kontext bereit und rendert Sidebar + Content.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { Outlet, Navigate, useLocation } from 'react-router-dom';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
import useNavigation from '../hooks/useNavigation';
import styles from './FeatureLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@ -44,7 +45,7 @@ const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' })
<h2>{t('Zugriff nicht möglich')}</h2>
<p>{message}</p>
<a href={returnPath} className={styles.errorLink}>
Zurück zur Übersicht
{t('Zurück zur Übersicht')}
</a>
</div>
);
@ -65,10 +66,27 @@ const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' })
* Bei Erfolg: Rendert <Outlet /> für die verschachtelten Routes
*/
export const FeatureLayout: React.FC = () => {
const { t } = useLanguage();
const location = useLocation();
const initialized = useFeaturesInitialized();
const loading = useFeaturesLoading();
const { instance, mandate, feature, isValid, isLoading } = useCurrentInstance();
const { instance, mandate, feature, isValid, isLoading, mandateId, featureCode, instanceId } = useCurrentInstance();
const { dynamicBlock } = useNavigation();
const navLabels = useMemo(() => {
if (!dynamicBlock || !mandateId) return null;
const navMandate = dynamicBlock.mandates.find(m => m.id === mandateId);
if (!navMandate) return null;
const navFeature = featureCode ? navMandate.features.find(f => f.uiComponent === featureCode) : undefined;
const navInstance = navFeature && instanceId
? navFeature.instances.find(i => i.id === instanceId)
: undefined;
return {
mandate: t(navMandate.uiLabel),
feature: navFeature ? t(navFeature.uiLabel) : undefined,
instance: navInstance ? t(navInstance.uiLabel) : undefined,
};
}, [dynamicBlock, mandateId, featureCode, instanceId, t]);
// Warten bis Features geladen sind
if (!initialized || loading || isLoading) {
@ -86,7 +104,7 @@ export const FeatureLayout: React.FC = () => {
return (
<ErrorScreen
message="Die angeforderte Feature-Instanz existiert nicht oder Sie haben keinen Zugriff."
message={t('Die angeforderte Feature-Instanz existiert nicht oder Sie haben keinen Zugriff.')}
/>
);
}
@ -97,11 +115,11 @@ export const FeatureLayout: React.FC = () => {
{/* Header mit Instanz-Info */}
<header className={styles.featureHeader}>
<div className={styles.breadcrumb}>
<span className={styles.mandateName}>{mandate?.name}</span>
<span className={styles.mandateName}>{navLabels?.mandate || mandate?.label || mandate?.name}</span>
<span className={styles.separator}>/</span>
<span className={styles.featureName}>{feature?.label?.de || feature?.code}</span>
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
<span className={styles.separator}>/</span>
<span className={styles.instanceName}>{instance?.instanceLabel}</span>
<span className={styles.instanceName}>{navLabels?.instance || instance?.instanceLabel}</span>
</div>
<div className={styles.roleIndicator}>
<span className={styles.roleBadge}>{instance?.userRoles?.join(', ') || '-'}</span>
@ -133,6 +151,7 @@ export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
requiredView,
children,
}) => {
const { t } = useLanguage();
const { instance, isValid } = useCurrentInstance();
if (!isValid) {
@ -146,7 +165,7 @@ export const ProtectedFeatureRoute: React.FC<ProtectedFeatureRouteProps> = ({
if (!hasViewAccess) {
return (
<ErrorScreen
message={`Sie haben keine Berechtigung für diesen Bereich (${requiredView}).`}
message={t('Sie haben keine Berechtigung für diesen Bereich ({view}).', { view: requiredView })}
returnPath={`/mandates/${instance?.mandateId}/${instance?.featureCode}/${instance?.id}`}
/>
);

View file

@ -84,13 +84,13 @@ const MainLayoutInner: React.FC = () => {
<nav className={styles.navigation}>
{loading && (
<div className={styles.loadingNav}>
Lade Navigation...
{t('Lade Navigation…')}
</div>
)}
{error && (
<div className={styles.errorNav}>
Fehler: {error}
{t('Fehler')}: {error}
</div>
)}

View file

@ -170,8 +170,8 @@ export const AutomationsDashboardPage: React.FC = () => {
sortable: true,
filterable: true,
formatter: (v: string) => (
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600, textTransform: 'capitalize' }}>
{v}
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
{t(v === 'completed' ? 'Abgeschlossen' : v === 'failed' ? 'Fehlgeschlagen' : v === 'running' ? 'Laufend' : v)}
</span>
),
},

View file

@ -85,7 +85,12 @@ export const DashboardPage: React.FC = () => {
<h1>{t('Übersicht')}</h1>
{totalInstances > 0 && (
<p className={styles.subtitle}>
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
{t('Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.', {
instanceCount: totalInstances,
instanceWord: totalInstances === 1 ? t('Feature-Instanz') : t('Feature-Instanzen'),
mandateCount: totalMandates,
mandateWord: totalMandates === 1 ? t('Mandant') : t('Mandanten'),
})}
</p>
)}
</header>

View file

@ -73,12 +73,12 @@ const ChatworkflowDashboard: React.FC = () => {
const ChatworkflowRuns: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title="Runs" description={t('Workflow-Ausführungen')} />;
return <PlaceholderView title={t('Ausführungen')} description={t('Workflow-Ausführungen')} />;
};
const ChatworkflowFiles: React.FC = () => {
const { t } = useLanguage();
return <PlaceholderView title={t('Dateien')} description="Workflow-Dateien" />;
return <PlaceholderView title={t('Dateien')} description={t('Workflow-Dateien')} />;
};
// Chatbot Views

View file

@ -68,7 +68,7 @@ export const GDPRPage: React.FC = () => {
} catch (error: any) {
console.error('Failed to load GDPR consent info:', error);
if (isActive) {
setConsentError('Consent information could not be loaded.');
setConsentError(t('Einwilligungsinformationen konnten nicht geladen werden.'));
}
} finally {
if (isActive) {
@ -82,7 +82,7 @@ export const GDPRPage: React.FC = () => {
return () => {
isActive = false;
};
}, []);
}, [t]);
const handleDataExport = async () => {
if (isActionLocked) return;
@ -91,10 +91,10 @@ export const GDPRPage: React.FC = () => {
try {
const response = await api.get('/api/user/me/data-export');
downloadJson(response.data, 'gdpr-data-export.json');
setActionMessage({ type: 'success', text: 'Data export downloaded.' });
setActionMessage({ type: 'success', text: t('Datenexport heruntergeladen.') });
} catch (error: any) {
console.error('GDPR export failed:', error);
setActionMessage({ type: 'error', text: 'Data export failed. Please try again.' });
setActionMessage({ type: 'error', text: t('Datenexport fehlgeschlagen. Bitte erneut versuchen.') });
} finally {
setIsExporting(false);
}
@ -109,10 +109,10 @@ export const GDPRPage: React.FC = () => {
headers: { Accept: 'application/ld+json' }
});
downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
setActionMessage({ type: 'success', text: 'Portable export downloaded.' });
setActionMessage({ type: 'success', text: t('Portabler Export heruntergeladen.') });
} catch (error: any) {
console.error('GDPR portability export failed:', error);
setActionMessage({ type: 'error', text: 'Portable export failed. Please try again.' });
setActionMessage({ type: 'error', text: t('Portabler Export fehlgeschlagen. Bitte erneut versuchen.') });
} finally {
setIsPortabilityExporting(false);
}
@ -121,7 +121,7 @@ export const GDPRPage: React.FC = () => {
const handleDeleteAccount = async () => {
setActionMessage(null);
if (deleteConfirmText !== 'LOESCHEN') {
setActionMessage({ type: 'error', text: 'Please type LOESCHEN to confirm deletion.' });
setActionMessage({ type: 'error', text: t('Bitte geben Sie LOESCHEN ein, um die Löschung zu bestätigen.') });
return;
}
@ -132,11 +132,11 @@ export const GDPRPage: React.FC = () => {
sessionStorage.removeItem('auth_authority');
clearUserDataCache();
setIsDeleted(true);
setActionMessage({ type: 'success', text: 'Account deleted. Redirecting to login...' });
setActionMessage({ type: 'success', text: t('Konto gelöscht. Weiterleitung zur Anmeldung…') });
window.location.replace('/login');
} catch (error: any) {
console.error('GDPR deletion failed:', error);
setActionMessage({ type: 'error', text: 'Account deletion failed. Please try again.' });
setActionMessage({ type: 'error', text: t('Kontolöschung fehlgeschlagen. Bitte erneut versuchen.') });
} finally {
setIsDeleting(false);
}
@ -148,14 +148,14 @@ export const GDPRPage: React.FC = () => {
<div>
<h1 className={styles.title}>
<FaShieldAlt className={styles.titleIcon} />
GDPR / Privacy
{t('DSGVO / Datenschutz')}
</h1>
<p className={styles.subtitle}>
Manage your personal data exports and account deletion.
{t('Verwalten Sie Ihre personenbezogenen Datenexporte und Kontolöschung.')}
</p>
</div>
<Link to="/settings" className={styles.backLink}>
Back to Settings
{t('Zurück zu Einstellungen')}
</Link>
</header>
@ -174,12 +174,12 @@ export const GDPRPage: React.FC = () => {
{isExporting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Exporting...
{t('Export wird erstellt…')}
</span>
) : (
<>
<FaDownload />
Export data
{t('Daten exportieren')}
</>
)}
</button>
@ -196,12 +196,12 @@ export const GDPRPage: React.FC = () => {
{isPortabilityExporting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Exporting...
{t('Export wird erstellt…')}
</span>
) : (
<>
<FaFileExport />
Export portable data
{t('Portabler Datenexport')}
</>
)}
</button>
@ -217,13 +217,15 @@ export const GDPRPage: React.FC = () => {
disabled={isActionLocked}
>
<FaTrash />
Start deletion
{t('Löschung starten')}
</button>
)}
{showDeleteConfirm && (
<div className={styles.deleteConfirm}>
<p className={styles.deleteWarning}>
This action is irreversible. Type <strong>LOESCHEN</strong> to confirm.
{t('Diese Aktion ist unwiderruflich. Geben Sie {word} ein, um zu bestätigen.', {
word: 'LOESCHEN',
})}
</p>
<input
className={styles.deleteInput}
@ -241,7 +243,7 @@ export const GDPRPage: React.FC = () => {
}}
disabled={isDeleting}
>
Cancel
{t('Abbrechen')}
</button>
<button
className={styles.dangerButton}
@ -251,12 +253,12 @@ export const GDPRPage: React.FC = () => {
{isDeleting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Deleting...
{t('Wird gelöscht…')}
</span>
) : (
<>
<FaTrash />
Confirm deletion
{t('Löschung bestätigen')}
</>
)}
</button>
@ -314,7 +316,7 @@ export const GDPRPage: React.FC = () => {
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Contact</h3>
<h3>{t('Kontakt')}</h3>
<ul>
{Object.entries({
...(consentInfo.contact || {}),

View file

@ -126,7 +126,7 @@ export const InvitePage: React.FC = () => {
sessionStorage.removeItem('auth_authority');
handleLoginRedirect();
} else {
setError(result.error || 'Fehler beim Annehmen der Einladung');
setError(result.error || t('Fehler beim Annehmen der Einladung'));
}
setAccepting(false);

View file

@ -168,7 +168,7 @@ function Login() {
}}
className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`}
/>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>Benutzername</label>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>{t('Benutzername')}</label>
</div>
<div className={styles.floatingLabelInput}>
<input
@ -190,7 +190,7 @@ function Login() {
</div>
<div className={styles.disclaimer}>
<p>
Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.
{t('Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')}
</p>
</div>
<button
@ -198,7 +198,7 @@ function Login() {
onClick={handleCredentialLogin}
disabled={isLoginLoading}
>
{isLoginLoading ? "wird geladen..." : "Anmelden"}
{isLoginLoading ? t('wird geladen…') : t('Anmelden')}
</button>
<div className={styles.passwordResetLink}>
@ -206,12 +206,12 @@ function Login() {
className={styles.textButton}
onClick={() => navigate("/password-reset-request")}
>
Passwort vergessen?
{t('Passwort vergessen?')}
</button>
</div>
<div className={styles.divider}>
<span>oder</span>
<span>{t('oder')}</span>
</div>
<button
@ -221,7 +221,7 @@ function Login() {
>
<div className={styles.buttonContent}>
<FaMicrosoft />
{isMsalLoading ? "Signing in..." : "Mit Microsoft anmelden"}
{isMsalLoading ? t('Anmeldung läuft…') : t('Mit Microsoft anmelden')}
</div>
</button>
@ -232,7 +232,7 @@ function Login() {
>
<div className={styles.buttonContent}>
<FaGoogle />
{isGoogleLoading ? "Signing in..." : "Mit Google anmelden"}
{isGoogleLoading ? t('Anmeldung läuft…') : t('Mit Google anmelden')}
</div>
</button>
@ -245,7 +245,7 @@ function Login() {
className={styles.ctaPrimary}
onClick={() => navigate('/register', { state: location.state })}
>
Kostenlos registrieren
{t('Kostenlos registrieren')}
</button>
</div>
</div>

View file

@ -28,26 +28,26 @@ function PasswordResetRequest() {
setValidationError(null);
if (!username.trim()) {
setValidationError('Bitte geben Sie Ihren Benutzernamen ein.');
setValidationError(t('Bitte geben Sie Ihren Benutzernamen ein.'));
return;
}
try {
await requestReset(username.trim());
setSuccessMessage('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.');
setSuccessMessage(t('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.'));
// Redirect to login after delay
setTimeout(() => {
navigate('/login', {
state: {
passwordResetRequested: true,
message: 'Bitte prüfen Sie Ihre E-Mail für den Passwort-Reset-Link.'
message: t('Bitte prüfen Sie Ihre E-Mail für den Passwort-Reset-Link.')
}
});
}, 5000);
} catch (err) {
// For security, still show success message even on error
setSuccessMessage('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.');
setSuccessMessage(t('Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet. Bitte prüfen Sie auch Ihren Spam-Ordner.'));
setTimeout(() => {
navigate('/login');
}, 5000);
@ -97,7 +97,7 @@ function PasswordResetRequest() {
}}
className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`}
/>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>Benutzername</label>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>{t('Benutzername')}</label>
</div>
<div className={styles.infoMessage}>
@ -109,7 +109,7 @@ function PasswordResetRequest() {
onClick={handleSubmit}
disabled={isLoading}
>
{isLoading ? "Wird gesendet..." : "Reset-Link anfordern"}
{isLoading ? t('Wird gesendet…') : t('Reset-Link anfordern')}
</button>
</>
)}
@ -120,7 +120,7 @@ function PasswordResetRequest() {
className={styles.textButton}
onClick={() => navigate("/login")}
>
Login
{t('Login')}
</button>
</div>
</div>

View file

@ -58,12 +58,12 @@ function Register() {
const _validateForm = (): boolean => {
if (!formData.username || !formData.email || !formData.fullName) {
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
setValidationError(t('Bitte füllen Sie alle Pflichtfelder aus.'));
return false;
}
if (!formData.email.includes('@')) {
setValidationError('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
setValidationError(t('Bitte geben Sie eine gültige E-Mail-Adresse ein.'));
return false;
}
@ -83,19 +83,19 @@ function Register() {
if (!availabilityResult.available) {
const errorMessage = availabilityResult.message || 'Username is not available';
if (errorMessage === 'Username is already taken') {
setValidationError('Benutzername ist bereits vergeben');
setValidationError(t('Benutzername ist bereits vergeben'));
setUsernameHighlight(true);
} else {
setValidationError('Benutzername ist nicht verfügbar');
setValidationError(t('Benutzername ist nicht verfügbar'));
}
return;
}
await register({ ...formData, registrationType: 'personal' });
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
let message = t('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.');
if (hasPendingInvitation) {
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
message += t(' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.');
}
setSuccessMessage(message);
@ -104,7 +104,7 @@ function Register() {
navigate('/login', {
state: {
registered: true,
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
message: t('Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.'),
...(location.state || {})
}
});
@ -116,9 +116,9 @@ function Register() {
const _getErrorMessage = () => {
if (validationError) return validationError;
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
if (availabilityError) return typeof availabilityError === 'string' ? availabilityError : 'Username availability check failed';
if (registerError) return typeof registerError === 'string' ? registerError : t('Registrierung fehlgeschlagen');
if (msalError) return typeof msalError === 'string' ? msalError : t('Microsoft-Registrierung fehlgeschlagen');
if (availabilityError) return typeof availabilityError === 'string' ? availabilityError : t('Benutzernamen-Prüfung fehlgeschlagen');
return null;
};
@ -163,7 +163,7 @@ function Register() {
onBlur={() => setUsernameFocused(false)}
className={`${styles.input} ${usernameFocused || formData.username ? styles.focused : ''} ${usernameHighlight ? styles.usernameError : ''}`}
/>
<label className={usernameFocused || formData.username ? styles.focusedLabel : styles.label}>Benutzername</label>
<label className={usernameFocused || formData.username ? styles.focusedLabel : styles.label}>{t('Benutzername')}</label>
</div>
<div className={styles.floatingLabelInput}>
@ -177,7 +177,7 @@ function Register() {
onBlur={() => setEmailFocused(false)}
className={`${styles.input} ${emailFocused || formData.email ? styles.focused : ''}`}
/>
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>{t('E-Mail')}</label>
</div>
<div className={styles.floatingLabelInput}>
@ -200,7 +200,7 @@ function Register() {
<div className={styles.disclaimer}>
<p>
Mit der Registrierung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.
{t('Mit der Registrierung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')}
</p>
</div>
@ -209,7 +209,7 @@ function Register() {
onClick={handleSubmit}
disabled={isLoading || isChecking}
>
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
{isLoading ? t('Registrierung läuft…') : isChecking ? t('Benutzername wird geprüft…') : t('Kostenlos registrieren')}
</button>
</>
)}
@ -220,7 +220,7 @@ function Register() {
className={styles.textButton}
onClick={() => navigate("/login", { state: location.state })}
>
Jetzt anmelden
{t('Jetzt anmelden')}
</button>
</div>
</div>

View file

@ -31,11 +31,11 @@ function Reset() {
// Validate token exists and format
if (!token) {
setTokenError('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.');
setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'));
} else if (!_isValidUUID(token)) {
setTokenError('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.');
setTokenError(t('Ungültiger Reset-Link. Bitte fordern Sie einen neuen Link an.'));
}
}, [token]);
}, [token, t]);
const _isValidUUID = (str: string): boolean => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -44,12 +44,12 @@ function Reset() {
const validateForm = (): boolean => {
if (!password || password.length < 8) {
setValidationError('Passwort muss mindestens 8 Zeichen lang sein.');
setValidationError(t('Passwort muss mindestens 8 Zeichen lang sein.'));
return false;
}
if (password !== confirmPassword) {
setValidationError('Die Passwörter stimmen nicht überein.');
setValidationError(t('Die Passwörter stimmen nicht überein.'));
return false;
}
@ -65,28 +65,28 @@ function Reset() {
}
if (!token) {
setValidationError('Token fehlt. Bitte fordern Sie einen neuen Reset-Link an.');
setValidationError(t('Token fehlt. Bitte fordern Sie einen neuen Reset-Link an.'));
return;
}
try {
await resetPassword(token, password);
setSuccessMessage('Passwort erfolgreich gesetzt! Sie werden zum Login weitergeleitet...');
setSuccessMessage(t('Passwort erfolgreich gesetzt! Sie werden zum Login weitergeleitet…'));
// Redirect to login after delay
setTimeout(() => {
navigate('/login', {
state: {
passwordReset: true,
message: 'Passwort erfolgreich geändert. Bitte melden Sie sich an.'
message: t('Passwort erfolgreich geändert. Bitte melden Sie sich an.')
}
});
}, 3000);
} catch (err: any) {
// Error is already set by the hook
const errorMessage = err?.response?.data?.detail || err?.message || 'Passwort-Zurücksetzung fehlgeschlagen.';
const errorMessage = err?.response?.data?.detail || err?.message || t('Passwort-Zurücksetzung fehlgeschlagen.');
if (errorMessage.includes('abgelaufen') || errorMessage.includes('expired') || errorMessage.includes('Ungültig') || errorMessage.includes('invalid')) {
setValidationError('Der Reset-Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.');
setValidationError(t('Der Reset-Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.'));
} else {
setValidationError(errorMessage);
}
@ -115,7 +115,7 @@ function Reset() {
className={styles.textButton}
onClick={() => navigate("/password-reset-request")}
>
Neuen Reset-Link anfordern
{t('Neuen Reset-Link anfordern')}
</button>
</div>
<div className={styles.registerLink}>
@ -124,7 +124,7 @@ function Reset() {
className={styles.textButton}
onClick={() => navigate("/login")}
>
Login
{t('Login')}
</button>
</div>
</div>
@ -200,7 +200,7 @@ function Reset() {
className={`${styles.button} ${styles.loginButton}`}
disabled={isLoading}
>
{isLoading ? "Wird gespeichert..." : "Passwort setzen"}
{isLoading ? t('Wird gespeichert…') : t('Passwort setzen')}
</button>
</form>
)}
@ -211,7 +211,7 @@ function Reset() {
className={styles.textButton}
onClick={() => navigate("/login")}
>
Login
{t('Login')}
</button>
</div>
</div>

View file

@ -60,7 +60,7 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
await onSave(formData);
onClose();
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern des Profils');
setError(err.message || t('Fehler beim Speichern des Profils'));
} finally {
setIsSaving(false);
}
@ -520,17 +520,17 @@ export const SettingsPage: React.FC = () => {
</div>
{currentUser && (
<div className={styles.userInfoCard}>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Benutzername')}</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('Name')}</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>{t('E-Mail')}</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
</div>
)}
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ueber</h2>
<h2 className={styles.sectionTitle}>{t('Applikation')}</h2>
<div className={styles.infoCard}>
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Version')}</span><span className={styles.infoValue}>2.0.0</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>{t('Build')}</span><span className={styles.infoValue}>2026.03.23</span></div>
</div>
</section>
</>

View file

@ -24,18 +24,13 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
return mandate.label || mandate.name || mandate.id;
}
function getFeatureLabel(feature: Feature, t: (k: string) => string): string {
return t(labelAsI18nKey(feature.label, feature.code));
return t(feature.label || feature.code);
}
export interface InstanceWithStats extends FeatureInstance {
@ -146,15 +141,19 @@ export const AccessManagementHub: React.FC = () => {
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
if (result.success && result.data) {
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}, Entfernt: ${result.data.removed}, Unverändert: ${result.data.unchanged}`
t('Rollen synchronisiert'),
t('Hinzugefügt: {added}, Entfernt: {removed}, Unverändert: {unchanged}', {
added: result.data.added,
removed: result.data.removed,
unchanged: result.data.unchanged,
})
);
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren');
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren'));
}
} catch {
showError('Fehler', 'Rollen konnten nicht synchronisiert werden');
showError(t('Fehler'), t('Rollen konnten nicht synchronisiert werden'));
}
};
@ -295,7 +294,7 @@ export const AccessManagementHub: React.FC = () => {
return {
id: inst.id,
label: inst.label,
featureLabel: feature ? getFeatureLabel(feature) : inst.featureCode,
featureLabel: feature ? getFeatureLabel(feature, t) : inst.featureCode,
userCount: inst.userCount ?? 0,
};
}),
@ -307,9 +306,11 @@ export const AccessManagementHub: React.FC = () => {
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
@ -320,9 +321,9 @@ export const AccessManagementHub: React.FC = () => {
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Zugriffsverwaltung</h1>
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
<p className={styles.pageSubtitle}>
Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
</p>
</div>
</div>
@ -334,7 +335,7 @@ export const AccessManagementHub: React.FC = () => {
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant:
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
@ -352,7 +353,7 @@ export const AccessManagementHub: React.FC = () => {
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
{t('Feature')}:
</label>
<select
className={styles.filterSelect}
@ -376,14 +377,14 @@ export const AccessManagementHub: React.FC = () => {
}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Neue Instanz erstellen
+ {t('Neue Instanz erstellen')}
</button>
</div>
)}
@ -398,21 +399,21 @@ export const AccessManagementHub: React.FC = () => {
className={viewMode === 'list' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('list')}
>
<FaList /> Listenansicht
<FaList /> {t('Listenansicht')}
</button>
<button
type="button"
className={viewMode === 'hierarchy' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('hierarchy')}
>
<FaSitemap /> Hierarchie
<FaSitemap /> {t('Hierarchie')}
</button>
</div>
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> Mandanten verwalten
<FaBuilding /> {t('Mandanten verwalten')}
</Link>
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
<FaUsers /> Mandant-Benutzer
<FaUsers /> {t('Mandant-Benutzer')}
</Link>
</div>
@ -423,7 +424,7 @@ export const AccessManagementHub: React.FC = () => {
instancesByMandate={instancesByMandate}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
getFeatureLabel={(f) => getFeatureLabel(f, t)}
loading={hierarchyUsersLoading}
onOpenDetail={handleOpenDetail}
/>
@ -432,7 +433,7 @@ export const AccessManagementHub: React.FC = () => {
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.
{t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')}
</p>
</div>
) : (
@ -444,7 +445,7 @@ export const AccessManagementHub: React.FC = () => {
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.instances}
</span>
<span className={hubStyles.statsLabel}>Instanzen</span>
<span className={hubStyles.statsLabel}>{t('Instanzen')}</span>
</div>
</div>
<div className={hubStyles.statsCard}>
@ -469,7 +470,7 @@ export const AccessManagementHub: React.FC = () => {
<div className={hubStyles.diagramCard}>
<FaLink className={hubStyles.statsIcon} />
<div className={hubStyles.diagramContent}>
<span className={hubStyles.diagramTitle}>Beziehungen</span>
<span className={hubStyles.diagramTitle}>{t('Beziehungen')}</span>
<div className={hubStyles.diagramFlow}>
<div className={hubStyles.diagramNode}>{relationshipData.mandateName}</div>
<div className={hubStyles.diagramNodes}>
@ -480,7 +481,7 @@ export const AccessManagementHub: React.FC = () => {
))}
{relationshipData.instances.length > 5 && (
<div className={hubStyles.diagramNodeSmall}>
+{relationshipData.instances.length - 5} weitere
+{relationshipData.instances.length - 5} {t('weitere')}
</div>
)}
</div>
@ -491,7 +492,7 @@ export const AccessManagementHub: React.FC = () => {
</div>
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Feature-Instanzen</h2>
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
{loading && filteredInstances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
@ -502,14 +503,14 @@ export const AccessManagementHub: React.FC = () => {
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanzen')}</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.
{t('Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.')}
</p>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Erste Instanz erstellen
+ {t('Erste Instanz erstellen')}
</button>
</div>
) : (
@ -526,8 +527,12 @@ export const AccessManagementHub: React.FC = () => {
</div>
<div className={hubStyles.instanceMeta}>
<span>{getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode }, t)}</span>
<span>{inst.userCount ?? '—'} Benutzer</span>
<span>{inst.roleCount ?? '—'} Rollen</span>
<span>
{inst.userCount ?? '—'} {t('Benutzer')}
</span>
<span>
{inst.roleCount ?? '—'} {t('Rollen')}
</span>
</div>
<div className={hubStyles.instanceActions}>
<button
@ -535,7 +540,7 @@ export const AccessManagementHub: React.FC = () => {
className={hubStyles.cardAction}
onClick={() => handleOpenDetail(inst, selectedMandateId)}
>
<FaUsers /> Benutzer verwalten
<FaUsers /> {t('Benutzer verwalten')}
</button>
<button
type="button"
@ -544,7 +549,7 @@ export const AccessManagementHub: React.FC = () => {
disabled={!inst.enabled}
title={t('Rollen synchronisieren')}
>
<FaCogs /> Rollen sync
<FaCogs /> {t('Rollen sync')}
</button>
</div>
</div>

View file

@ -20,7 +20,6 @@ import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
export const AdminFeatureAccessPage: React.FC = () => {
const { t } = useLanguage();
@ -94,7 +93,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
render: (value: string) => {
const feature = features.find(f => f.code === value);
if (feature) {
return t(labelAsI18nKey(feature.label, value));
return t(feature.label || value);
}
return value;
}
@ -122,7 +121,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
try {
// Validate label
if (!createLabel || createLabel.trim() === '') {
showError('Fehler', 'Label ist erforderlich.');
showError(t('Fehler'), t('Label ist erforderlich.'));
setIsSubmitting(false);
return;
}
@ -132,7 +131,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (createFeatureCode === 'chatbot') {
// Validate required fields
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
showError('Fehler', 'System Prompt ist erforderlich für Chatbot-Instanzen.');
showError(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
setIsSubmitting(false);
return;
}
@ -176,9 +175,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`);
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
} else {
showError('Fehler', result.error || 'Fehler beim Erstellen der Feature-Instanz');
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Feature-Instanz'));
}
} finally {
setIsSubmitting(false);
@ -228,7 +227,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (editingInstance.featureCode === 'chatbot') {
// Validate required fields
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
showError('Fehler', 'System Prompt ist erforderlich für Chatbot-Instanzen.');
showError(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
setIsSubmitting(false);
return;
}
@ -270,9 +269,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
} else {
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Feature-Instanz'));
}
} finally {
setIsSubmitting(false);
@ -285,10 +284,10 @@ export const AdminFeatureAccessPage: React.FC = () => {
const result = await deleteInstance(selectedMandateId, instanceId);
if (result.success) {
loadFeatures(); // Refresh global navigation cache
showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
showSuccess(t('Instanz gelöscht'), t('Die Feature-Instanz wurde gelöscht.'));
return true;
} else {
showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Feature-Instanz'));
return false;
}
};
@ -301,11 +300,15 @@ export const AdminFeatureAccessPage: React.FC = () => {
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
if (result.success && result.data) {
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}\nEntfernt: ${result.data.removed}\nUnverändert: ${result.data.unchanged}`
t('Rollen synchronisiert'),
t('Hinzugefügt: {added}\nEntfernt: {removed}\nUnverändert: {unchanged}', {
added: result.data.added,
removed: result.data.removed,
unchanged: result.data.unchanged,
})
);
} else {
showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren der Rollen');
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren der Rollen'));
}
} finally {
setSyncingInstance(null);
@ -314,18 +317,14 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
return mandate.label || mandate.name || mandate.id;
};
// Get feature label
const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code);
if (feature) {
return t(labelAsI18nKey(feature.label, code));
return t(feature.label || code);
}
return code;
};
@ -337,7 +336,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
@ -348,7 +347,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
<h1 className={styles.pageTitle}>{t('Feature-Instanzen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie Feature-Instanzen für jeden')}</p>
</div>
</div>
@ -358,7 +357,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant auswählen:
{t('Mandant auswählen:')}
</label>
<select
className={styles.filterSelect}
@ -381,15 +380,19 @@ export const AdminFeatureAccessPage: React.FC = () => {
onClick={() => fetchInstances(selectedMandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
disabled={features.length === 0}
title={features.length === 0 ? 'Keine Features verfügbar. Bitte laden Sie die Seite neu oder prüfen Sie die Konsole auf Fehler.' : undefined}
title={
features.length === 0
? t('Keine Features verfügbar. Bitte laden Sie die Seite neu oder prüfen Sie die Konsole auf Fehler.')
: undefined
}
>
<FaPlus /> Neue Instanz
<FaPlus /> {t('Neue Instanz')}
</button>
</div>
)}
@ -411,16 +414,17 @@ export const AdminFeatureAccessPage: React.FC = () => {
<div className={styles.infoBox} style={{ borderColor: 'var(--error-color, #dc3545)', backgroundColor: 'var(--error-bg, rgba(220, 53, 69, 0.1))' }}>
<FaCube style={{ marginRight: 8 }} />
<span>
Keine Features geladen.
{error ? ` Fehler: ${error}` : ' Die API hat keine Features zurückgegeben.'}
{' '}Öffnen Sie die Browser-Konsole (F12) und prüfen Sie den Netzwerk-Tab für /api/features/
{t('Keine Features geladen.')}
{error ? ` Fehler: ${error}` : ` ${t('Die API hat keine Features zurückgegeben.')}`}
{' '}
{t('Öffnen Sie die Browser-Konsole (F12) und prüfen Sie den Netzwerk-Tab für /api/features/')}
</span>
<button
className={styles.secondaryButton}
onClick={() => fetchFeatures()}
style={{ marginLeft: '1rem' }}
>
<FaSync /> Features erneut laden
<FaSync /> {t('Features erneut laden')}
</button>
</div>
) : null}
@ -431,7 +435,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
{t('Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.')}
</p>
</div>
) : (
@ -505,12 +509,12 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Feature Code Selector - Required for chatbot config */}
<div className={styles.configField} style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border-color)' }}>
<label className={styles.configLabel} style={{ fontWeight: 600 }}>
Feature auswählen: <span style={{ color: 'var(--error-color)' }}>*</span>
{t('Feature auswählen')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<DropdownSelect
items={features.map(f => ({
id: f.code,
label: t(labelAsI18nKey(f.label, f.code)),
label: t(f.label || f.code),
value: f.code
}))}
selectedItemId={createFeatureCode}
@ -528,7 +532,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
/>
{!createFeatureCode && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
Bitte wählen Sie ein Feature aus, um fortzufahren.
{t('Bitte wählen Sie ein Feature aus, um fortzufahren.')}
</p>
)}
</div>
@ -536,7 +540,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
{/* Chatbot Configuration Title - Show when chatbot is selected */}
{createFeatureCode === 'chatbot' && (
<h3 className={styles.configSectionTitle} style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
Chatbot-Konfiguration
{t('Chatbot-Konfiguration')}
</h3>
)}
@ -544,7 +548,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
{createFeatureCode && (
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
<label className={styles.configLabel}>
Label: <span style={{ color: 'var(--error-color)' }}>*</span>
{t('Label')}: <span style={{ color: 'var(--error-color)' }}>*</span>
</label>
<TextField
type="text"

View file

@ -17,7 +17,6 @@ import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { t } = useLanguage();
@ -93,7 +92,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
allOptions.push({
mandateId: mandate.id,
instanceId: inst.id,
mandateName: mandate.label || (typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id)),
mandateName: mandate.label || mandate.name || mandate.id,
instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`,
@ -314,9 +313,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
setShowAddModal(false);
refreshUsers();
loadFeatures(); // Refresh global navigation cache
showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.');
showSuccess(t('Benutzer hinzugefügt'), t('Der Benutzer wurde erfolgreich zur Feature-Instanz hinzugefügt.'));
} else {
showError('Fehler', result.error || 'Fehler beim Hinzufügen des Benutzers');
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
}
} finally {
setIsSubmitting(false);
@ -338,9 +337,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
setEditingUser(null);
refreshUsers();
loadFeatures(); // Refresh global navigation cache
showSuccess('Eintrag aktualisiert', 'Rollen und Aktiv-Status wurden erfolgreich aktualisiert.');
showSuccess(t('Eintrag aktualisiert'), t('Rollen und Aktiv-Status wurden erfolgreich aktualisiert.'));
} else {
showError('Fehler', result.error || 'Fehler beim Aktualisieren');
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren'));
}
} finally {
setIsSubmitting(false);
@ -354,9 +353,9 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
if (result.success) {
refreshUsers();
loadFeatures(); // Refresh global navigation cache
showSuccess('Benutzer entfernt', `"${user.username}" wurde aus der Feature-Instanz entfernt.`);
showSuccess(t('Benutzer entfernt'), t('"{name}" wurde aus der Feature-Instanz entfernt.', { name: user.username }));
} else {
showError('Fehler', result.error || 'Fehler beim Entfernen des Benutzers');
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
}
};
@ -369,7 +368,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code);
if (feature) {
return t(labelAsI18nKey(feature.label, code));
return t(feature.label || code);
}
return code;
};
@ -396,9 +395,11 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
@ -419,7 +420,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
<div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Mandant / Feature-Instanz:
{t('Mandant / Feature-Instanz')}:
</label>
<select
className={styles.filterSelect}
@ -458,14 +459,14 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
onClick={() => refreshUsers()}
disabled={usersLoading}
>
<FaSync className={usersLoading ? 'spinning' : ''} /> Aktualisieren
<FaSync className={usersLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Benutzer hinzufügen
<FaPlus /> {t('Benutzer hinzufügen')}
</button>
</div>
)}
@ -475,10 +476,14 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{selectedOption && (
<div className={styles.infoBox}>
<FaBuilding style={{ marginRight: 8 }} />
<span>Mandant: <strong>{selectedOption.mandateName}</strong></span>
<span>
{t('Mandant')}: <strong>{selectedOption.mandateName}</strong>
</span>
<span style={{ margin: '0 16px', color: 'var(--color-border)' }}>|</span>
<FaCube style={{ marginRight: 8 }} />
<span>Instanz: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})</span>
<span>
{t('Instanz')}: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})
</span>
</div>
)}
@ -497,7 +502,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
{/* Warning if no roles available */}
{selectedInstance && instanceRoles.length === 0 && !usersLoading && (
<div className={styles.warningBox || styles.infoBox}>
<div className={styles.infoBox} style={{ borderColor: 'var(--warning-color, #d69e2e)', backgroundColor: 'var(--warning-bg, rgba(214, 158, 46, 0.12))' }}>
<span> </span>
<span>{t('Diese Instanz hat noch keine')}</span>
</div>
@ -595,7 +600,9 @@ export const AdminFeatureInstanceUsersPage: 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}>Rollen bearbeiten: {editingUser.username}</h2>
<h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username}
</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}

View file

@ -23,18 +23,18 @@ import { useLanguage } from '../../providers/language/LanguageContext';
interface Feature {
id?: string;
code: string; // Backend uses 'code' not 'featureCode'
featureCode?: string; // Alias for backward compatibility
label: string | { [key: string]: string }; // Backend uses 'label' not 'name'
name?: string | { [key: string]: string }; // Alias for backward compatibility
description?: string | { [key: string]: string };
code: string;
featureCode?: string;
label: string;
name?: string;
description?: string;
icon?: string;
}
interface FeatureRole {
id: string;
roleLabel: string;
description?: { [key: string]: string };
description?: string;
featureCode: string;
mandateId?: string | null;
featureInstanceId?: string | null;
@ -118,17 +118,14 @@ export const AdminFeatureRolesPage: React.FC = () => {
} finally {
setLoading(false);
}
}, [selectedFeatureCode]);
}, [selectedFeatureCode, t]);
useEffect(() => {
fetchRoles();
}, [fetchRoles]);
// Get text from multilingual object
const getTextValue = (value: string | { [key: string]: string } | undefined): string => {
if (!value) return '-';
if (typeof value === 'string') return value;
return value.de || value.en || Object.values(value)[0] || '-';
const getTextValue = (value: string | undefined): string => {
return value || '-';
};
// Table columns
@ -148,7 +145,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
type: 'string' as const,
sortable: false,
width: 300,
formatter: (value: string | { [key: string]: string }) => getTextValue(value)
formatter: (value: string) => getTextValue(value)
},
{
key: 'featureCode',
@ -178,9 +175,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
{
name: 'description',
label: t('Beschreibung'),
type: 'multilingual',
type: 'textarea',
required: false,
description: t('Mehrsprachige Beschreibung')
description: t('Beschreibung der Rolle')
}
];
return fields;
@ -200,15 +197,15 @@ export const AdminFeatureRolesPage: React.FC = () => {
{
name: 'description',
label: t('Beschreibung'),
type: 'multilingual',
type: 'textarea',
required: false,
description: t('Mehrsprachige Beschreibung')
description: t('Beschreibung der Rolle')
}
];
}, [t]);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
const handleCreateRole = async (data: { roleLabel: string; description?: string }) => {
if (!selectedFeatureCode) return;
setIsSubmitting(true);
try {
@ -216,20 +213,20 @@ export const AdminFeatureRolesPage: React.FC = () => {
params.append('roleLabel', data.roleLabel);
params.append('featureCode', selectedFeatureCode);
await api.post(`/api/features/templates/roles?${params.toString()}`, data.description || {});
await api.post(`/api/features/templates/roles?${params.toString()}`, data.description ?? '');
setShowCreateModal(false);
await fetchRoles();
} catch (err: any) {
console.error('Error creating role:', err);
showError('Fehler', err.response?.data?.detail || 'Fehler beim Erstellen der Rolle');
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Erstellen der Rolle'));
} finally {
setIsSubmitting(false);
}
};
// Handle edit role
const handleEditRole = async (data: { roleLabel: string; description?: { [key: string]: string } }) => {
const handleEditRole = async (data: { roleLabel: string; description?: string }) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
@ -241,7 +238,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
await fetchRoles();
} catch (err: any) {
console.error('Error updating role:', err);
showError('Fehler', err.response?.data?.detail || 'Fehler beim Aktualisieren der Rolle');
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Aktualisieren der Rolle'));
} finally {
setIsSubmitting(false);
}
@ -254,7 +251,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
await fetchRoles();
} catch (err: any) {
console.error('Error deleting role:', err);
showError('Fehler', err.response?.data?.detail || 'Fehler beim Löschen der Rolle');
showError(t('Fehler'), err.response?.data?.detail || t('Fehler beim Löschen der Rolle'));
}
};
@ -276,7 +273,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{error}</p>
<button className={styles.secondaryButton} onClick={() => window.location.reload()}>
<FaSync /> Erneut versuchen
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
@ -297,7 +294,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
{t('Feature:')}
</label>
<select
className={styles.filterSelect}
@ -323,13 +320,13 @@ export const AdminFeatureRolesPage: React.FC = () => {
onClick={() => fetchRoles()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Feature-Rolle
<FaPlus /> {t('Neue Feature-Rolle')}
</button>
</div>
)}
@ -340,8 +337,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>Feature-Template-Rollen</strong> werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert.
Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.
<strong>{t('Feature-Template-Rollen')}</strong>{' '}
{t('werden bei der Erstellung neuer Feature-Instanzen automatisch kopiert. Änderungen an Template-Rollen wirken sich nicht auf bestehende Instanzen aus.')}
</span>
</div>
)}
@ -352,7 +349,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Feature ausgewählt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
{t('Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.')}
</p>
</div>
) : (
@ -414,14 +411,16 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{selectedFeatureCode}</strong></span>
<span>
{t('Feature')}: <strong>{selectedFeatureCode}</strong>
</span>
</div>
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Rolle erstellen'}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
cancelButtonText={t('Abbrechen')}
/>
</div>
@ -445,7 +444,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{editingRole.featureCode}</strong></span>
<span>{t('Feature:')} <strong>{editingRole.featureCode}</strong></span>
</div>
<FormGeneratorForm
attributes={editFields}
@ -468,7 +467,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
<FaShieldAlt style={{ marginRight: 8 }} />
Berechtigungen: {permissionsRole.roleLabel}
{t('Berechtigungen')}: {permissionsRole.roleLabel}
</h2>
<button
className={styles.modalClose}
@ -480,7 +479,9 @@ export const AdminFeatureRolesPage: React.FC = () => {
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{permissionsRole.featureCode}</strong></span>
<span>
{t('Feature')}: <strong>{permissionsRole.featureCode}</strong>
</span>
<span style={{ marginLeft: '1rem' }}>{t('Template-Rolle global')}</span>
</div>
<AccessRulesEditor

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