From bc091c399cef2a0245cfa80855854357ff795de2 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 24 Mar 2026 14:16:45 +0100 Subject: [PATCH 01/22] unified data - step 1 --- src/api/authApi.ts | 8 +- src/api/storeApi.ts | 46 +++- src/components/OnboardingAssistant.tsx | 206 ++++++++++++++++++ src/components/OnboardingWizard.tsx | 119 ++++++++++ .../UiComponents/Messages/MessagesTypes.ts | 6 + .../UnifiedDataBar/ChatsTab.module.css | 134 ++++++++++++ src/components/UnifiedDataBar/ChatsTab.tsx | 186 ++++++++++++++++ .../UnifiedDataBar/FilesTab.module.css | 94 ++++++++ src/components/UnifiedDataBar/FilesTab.tsx | 134 ++++++++++++ .../UnifiedDataBar/SourcesTab.module.css | 11 + src/components/UnifiedDataBar/SourcesTab.tsx | 24 ++ .../UnifiedDataBar/UnifiedDataBar.module.css | 60 +++++ .../UnifiedDataBar/UnifiedDataBar.tsx | 69 ++++++ src/components/UnifiedDataBar/index.ts | 6 + .../UnifiedDataBar/useUdlContext.ts | 23 ++ src/hooks/useAuthentication.ts | 1 + src/hooks/useStore.ts | 42 +++- src/pages/Login.module.css | 44 ++++ src/pages/Login.tsx | 39 +++- src/pages/Register.tsx | 37 +++- src/pages/Store.module.css | 111 +++++++++- src/pages/Store.tsx | 146 ++++++++++--- .../commcoach/CommcoachDossierView.module.css | 51 ++++- .../views/commcoach/CommcoachDossierView.tsx | 150 ++++--------- .../views/commcoach/CommcoachSettingsView.tsx | 16 ++ src/pages/views/workspace/ChatStream.tsx | 26 +++ .../views/workspace/NeutralizationPanel.tsx | 191 ++++++++++++++++ src/pages/views/workspace/WorkspaceInput.tsx | 18 +- src/pages/views/workspace/WorkspacePage.tsx | 81 +++---- .../views/workspace/WorkspaceSettingsPage.tsx | 7 +- 30 files changed, 1876 insertions(+), 210 deletions(-) create mode 100644 src/components/OnboardingAssistant.tsx create mode 100644 src/components/OnboardingWizard.tsx create mode 100644 src/components/UnifiedDataBar/ChatsTab.module.css create mode 100644 src/components/UnifiedDataBar/ChatsTab.tsx create mode 100644 src/components/UnifiedDataBar/FilesTab.module.css create mode 100644 src/components/UnifiedDataBar/FilesTab.tsx create mode 100644 src/components/UnifiedDataBar/SourcesTab.module.css create mode 100644 src/components/UnifiedDataBar/SourcesTab.tsx create mode 100644 src/components/UnifiedDataBar/UnifiedDataBar.module.css create mode 100644 src/components/UnifiedDataBar/UnifiedDataBar.tsx create mode 100644 src/components/UnifiedDataBar/index.ts create mode 100644 src/components/UnifiedDataBar/useUdlContext.ts create mode 100644 src/pages/views/workspace/NeutralizationPanel.tsx diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 8343584..30ce6bb 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -27,6 +27,8 @@ export interface RegisterData { language?: string; enabled?: boolean; privilege?: string; + registrationType?: 'personal' | 'company'; + companyName?: string; } export interface RegisterRequest { @@ -40,6 +42,8 @@ export interface RegisterRequest { authenticationAuthority: string; }; frontendUrl: string; + registrationType?: string; + companyName?: string; } export interface PasswordResetRequestResponse { @@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise; icon: string; description: Record; - isActive: boolean; + instances: StoreFeatureInstance[]; canActivate: boolean; - instanceId: string | null; } export interface StoreActivateResponse { @@ -31,17 +38,44 @@ export interface StoreDeactivateResponse { deactivated: boolean; } +export interface UserMandate { + id: string; + name: string; + label: string; + mandateType: string; +} + +export interface SubscriptionInfo { + plan: string | null; + status: string | null; + maxDataVolumeMB: number | null; + maxFeatureInstances: number | null; + currentFeatureInstances: number; + trialEndsAt: string | null; +} + export async function fetchStoreFeatures(): Promise { const response = await api.get('/api/store/features'); return response.data; } -export async function activateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/activate', { featureCode }); +export async function fetchUserMandates(): Promise { + const response = await api.get('/api/store/mandates'); return response.data; } -export async function deactivateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/deactivate', { featureCode }); +export async function fetchSubscriptionInfo(mandateId?: string): Promise { + const params = mandateId ? { mandateId } : {}; + const response = await api.get('/api/store/subscription-info', { params }); + return response.data; +} + +export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise { + const response = await api.post('/api/store/activate', { featureCode, mandateId }); + return response.data; +} + +export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise { + const response = await api.post('/api/store/deactivate', { featureCode, mandateId, instanceId }); return response.data; } diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx new file mode 100644 index 0000000..97bd5b0 --- /dev/null +++ b/src/components/OnboardingAssistant.tsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import api from '../api'; + +interface OnboardingStep { + id: string; + label: string; + description: string; + completed: boolean; + action?: () => void; +} + +interface OnboardingAssistantProps { + instanceId?: string; + mandateId?: string; + featureCode?: string; + onDismiss?: () => void; +} + +const _DISMISS_KEY = 'onboarding_dismissed'; +const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; + +const OnboardingAssistant: React.FC = ({ + instanceId, + mandateId, + featureCode, + onDismiss, +}) => { + const navigate = useNavigate(); + const [dismissed, setDismissed] = useState(false); + const [steps, setSteps] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + try { + const dismissedAt = localStorage.getItem(_DISMISS_KEY); + if (dismissedAt && Date.now() - parseInt(dismissedAt) < _DISMISS_COOLDOWN_MS) { + setDismissed(true); + } + } catch { /* ignore */ } + }, []); + + useEffect(() => { + _checkOnboardingState(); + }, [instanceId, mandateId]); + + const _checkOnboardingState = async () => { + setLoading(true); + try { + const onboardingSteps: OnboardingStep[] = []; + + let hasMandate = !!mandateId; + if (!hasMandate) { + try { + const mandatesRes = await api.get('/api/store/mandates'); + hasMandate = (mandatesRes.data || []).length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'mandate', + label: 'Mandant einrichten', + description: hasMandate ? 'Dein Mandant ist eingerichtet.' : 'Richte deinen Mandanten ein, um loszulegen.', + completed: hasMandate, + action: hasMandate ? undefined : () => navigate('/store'), + }); + + let hasInstances = !!instanceId; + if (!hasInstances) { + try { + const storeRes = await api.get('/api/store/features'); + const features = storeRes.data || []; + hasInstances = features.some((f: any) => f.instances && f.instances.length > 0); + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'feature', + label: 'Erstes Feature aktivieren', + description: hasInstances ? 'Du hast aktive Features.' : 'Aktiviere dein erstes Feature im Store.', + completed: hasInstances, + action: hasInstances ? undefined : () => navigate('/store'), + }); + + let hasData = false; + if (instanceId) { + try { + const filesRes = await api.get(`/api/workspace/${instanceId}/files`); + const files = filesRes.data?.data || filesRes.data || []; + hasData = files.length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'data', + label: 'Erste Datenquelle einbinden', + description: hasData ? 'Du hast Daten im Workspace.' : 'Lade eine Datei hoch oder verbinde eine Datenquelle.', + completed: hasData, + }); + + let hasChats = false; + if (instanceId) { + try { + const chatsRes = await api.get(`/api/workspace/${instanceId}/workflows`); + const chats = chatsRes.data?.data || chatsRes.data || []; + hasChats = chats.length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'chat', + label: 'Ersten AI-Chat starten', + description: hasChats ? 'Du hast bereits Chats.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.', + completed: hasChats, + }); + + setSteps(onboardingSteps); + + if (onboardingSteps.every(s => s.completed)) { + setDismissed(true); + } + } catch (err) { + console.error('Onboarding check failed:', err); + } finally { + setLoading(false); + } + }; + + const _handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + try { + localStorage.setItem(_DISMISS_KEY, Date.now().toString()); + } catch { /* ignore */ } + }; + + if (dismissed || loading) return null; + + const completedCount = steps.filter(s => s.completed).length; + if (completedCount === steps.length) return null; + + return ( +
+
+
+

Willkommen bei PowerOn

+

+ {completedCount} von {steps.length} Schritten abgeschlossen +

+
+ +
+ +
+ {steps.map((step) => ( +
+ ))} +
+ +
+ {steps.map((step) => ( +
+ + {step.completed ? '\u2713' : '\u25CB'} + +
+
+ {step.label} +
+
+ {step.description} +
+
+ {step.action && !step.completed && ( + {'\u2192'} + )} +
+ ))} +
+
+ ); +}; + +export default OnboardingAssistant; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..5815cbb --- /dev/null +++ b/src/components/OnboardingWizard.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import api from '../api'; + +interface OnboardingWizardProps { + onComplete: () => void; + onDismiss: () => void; +} + +const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => { + const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal'); + const [companyName, setCompanyName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const _handleSubmit = async () => { + setLoading(true); + setError(null); + try { + await api.post('/api/local/onboarding', { + mandateType, + companyName: mandateType === 'company' ? companyName : undefined, + }); + onComplete(); + } catch (err: any) { + setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Willkommen bei PowerOn

+

+ Wie möchtest du PowerOn nutzen? +

+ +
+ + + +
+ + {mandateType === 'company' && ( +
+ + setCompanyName(e.target.value)} + placeholder="Name des Unternehmens" + style={{ + width: '100%', padding: '10px 12px', borderRadius: '6px', + border: '1px solid var(--border, #d1d5db)', fontSize: '1rem', + boxSizing: 'border-box', + }} + /> +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/src/components/UiComponents/Messages/MessagesTypes.ts b/src/components/UiComponents/Messages/MessagesTypes.ts index 7aa81b3..04ac70a 100644 --- a/src/components/UiComponents/Messages/MessagesTypes.ts +++ b/src/components/UiComponents/Messages/MessagesTypes.ts @@ -15,6 +15,12 @@ export interface MessageDocument { taskNumber: number; actionNumber: number; actionId: string; + documentName?: string; + validationMetadata?: { + neutralized?: boolean; + skipped?: boolean; + [key: string]: unknown; + }; } /** diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css new file mode 100644 index 0000000..5118b5b --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -0,0 +1,134 @@ +.chatsTab { + display: flex; + flex-direction: column; + gap: 8px; +} + +.toolbar { + display: flex; + gap: 6px; + align-items: center; +} + +.search { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + font-size: 0.85rem; + background: var(--bg-input, #fff); + color: var(--text-primary, #111); +} + +.modeToggle { + padding: 6px 8px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.85rem; +} + +.modeActive { + background: var(--bg-active, #eef2ff); +} + +.loading { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); +} + +.flatList, +.tree { + display: flex; + flex-direction: column; +} + +.chatItem { + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; +} + +.chatItem:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.chatLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.chatDate { + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + margin-left: 8px; +} + +.treeGroup { + margin-bottom: 2px; +} + +.treeGroupHeader { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.85rem; +} + +.treeGroupHeader:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.treeGroupCurrent { + color: var(--accent, #4f46e5); +} + +.treeArrow { + font-size: 0.7rem; + width: 12px; +} + +.treeGroupLabel { + flex: 1; +} + +.treeGroupCount { + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + background: var(--bg-badge, #f3f4f6); + padding: 1px 6px; + border-radius: 10px; +} + +.treeChildren { + padding-left: 20px; +} + +@media (prefers-color-scheme: dark) { + .search { + background: var(--bg-input-dark, #1f2937); + border-color: var(--border-dark, #374151); + color: #f3f4f6; + } + .chatItem:hover, + .treeGroupHeader:hover { + background: rgba(255, 255, 255, 0.05); + } + .treeGroupCount { + background: #374151; + color: #9ca3af; + } +} diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx new file mode 100644 index 0000000..9391756 --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import styles from './ChatsTab.module.css'; + +interface ChatItem { + id: string; + label: string; + updatedAt?: string; + featureInstanceId?: string; + featureCode?: string; +} + +interface ChatGroup { + featureInstanceId: string; + featureLabel: string; + featureCode: string; + chats: ChatItem[]; +} + +interface ChatsTabProps { + context: UdbContext; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + onDragStart?: (chatId: string, event: React.DragEvent) => void; +} + +const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }) => { + const [groups, setGroups] = useState([]); + const [flatMode, setFlatMode] = useState(false); + const [search, setSearch] = useState(''); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [loading, setLoading] = useState(true); + + const _loadChats = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${context.instanceId}/workflows`); + const workflows = response.data?.data || response.data || []; + + const groupMap = new Map(); + for (const wf of workflows) { + const fiId = wf.featureInstanceId || context.instanceId; + if (!groupMap.has(fiId)) { + groupMap.set(fiId, { + featureInstanceId: fiId, + featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8), + featureCode: wf.featureCode || 'workspace', + chats: [], + }); + } + groupMap.get(fiId)!.chats.push({ + id: wf.id, + label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`, + updatedAt: wf.updatedAt || wf.createdAt, + featureInstanceId: fiId, + featureCode: wf.featureCode, + }); + } + + const sorted = Array.from(groupMap.values()); + sorted.forEach(g => + g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')), + ); + setGroups(sorted); + + if (expandedGroups.size === 0 && sorted.length > 0) { + setExpandedGroups(new Set([context.instanceId])); + } + } catch (err) { + console.error('Failed to load chats:', err); + } finally { + setLoading(false); + } + }, [context.instanceId]); + + useEffect(() => { _loadChats(); }, [_loadChats]); + + const _toggleGroup = (id: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const _filteredGroups = groups + .map(g => ({ + ...g, + chats: search + ? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())) + : g.chats, + })) + .filter(g => g.chats.length > 0); + + const _allChats = _filteredGroups + .flatMap(g => g.chats) + .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + + if (loading) return
Lade Chats...
; + + return ( +
+
+ setSearch(e.target.value)} + /> + +
+ + {flatMode ? ( +
+ {_allChats.map((chat) => ( +
onSelectChat?.(chat.id, chat.featureInstanceId || context.instanceId)} + draggable={!!onDragStart} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {chat.label} + {chat.updatedAt && ( + + {new Date(chat.updatedAt).toLocaleDateString()} + + )} +
+ ))} +
+ ) : ( +
+ {_filteredGroups.map((group) => ( +
+
_toggleGroup(group.featureInstanceId)} + > + + {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} + + {group.featureLabel} + {group.chats.length} +
+ {expandedGroups.has(group.featureInstanceId) && ( +
+ {group.chats.map((chat) => ( +
onSelectChat?.(chat.id, group.featureInstanceId)} + draggable={!!onDragStart} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {chat.label} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default ChatsTab; diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css new file mode 100644 index 0000000..a79c04c --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -0,0 +1,94 @@ +.filesTab { + display: flex; + flex-direction: column; + height: 100%; +} + +.loading, +.empty { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; +} + +.fileList { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; +} + +.fileRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.fileRow:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.fileName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileIcons { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.scopeIcon, +.neutralizeIcon { + border: none; + background: transparent; + cursor: pointer; + font-size: 0.9rem; + padding: 2px 4px; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.scopeIcon:hover, +.neutralizeIcon:hover { + opacity: 1; + background: var(--bg-hover, rgba(0, 0, 0, 0.06)); +} + +.neutralizeActive { + opacity: 1; +} + +.legend { + display: flex; + gap: 12px; + padding: 8px 10px; + border-top: 1px solid var(--border-color, #e5e7eb); + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + flex-wrap: wrap; +} + +@media (prefers-color-scheme: dark) { + .fileRow:hover { + background: rgba(255, 255, 255, 0.05); + } + .scopeIcon:hover, + .neutralizeIcon:hover { + background: rgba(255, 255, 255, 0.08); + } + .legend { + border-top-color: var(--border-dark, #374151); + } +} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx new file mode 100644 index 0000000..65183c9 --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import styles from './FilesTab.module.css'; + +interface FileEntry { + id: string; + fileName: string; + mimeType?: string; + scope: string; + neutralize: boolean; + fileSize?: number; +} + +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; + +interface FilesTabProps { + context: UdbContext; + onFileSelect?: (fileId: string) => void; +} + +const FilesTab: React.FC = ({ context, onFileSelect }) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadFiles = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${context.instanceId}/files`); + const data = response.data?.data || response.data || []; + setFiles( + data.map((f: any) => ({ + id: f.id, + fileName: f.fileName || f.name || 'unknown', + mimeType: f.mimeType, + scope: f.scope || 'personal', + neutralize: f.neutralize || false, + fileSize: f.fileSize, + })), + ); + } catch (err) { + console.error('Failed to load files:', err); + } finally { + setLoading(false); + } + }, [context.instanceId]); + + useEffect(() => { + _loadFiles(); + }, [_loadFiles]); + + const _cycleScope = async (file: FileEntry) => { + const currentIdx = _SCOPE_CYCLE.indexOf(file.scope); + const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length]; + try { + await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope }); + setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f))); + } catch (err) { + console.error('Failed to update scope:', err); + } + }; + + const _toggleNeutralize = async (file: FileEntry) => { + try { + await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize }); + setFiles(prev => + prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)), + ); + } catch (err) { + console.error('Failed to toggle neutralize:', err); + } + }; + + if (loading) return
Lade Dateien...
; + + return ( +
+ {files.length === 0 ? ( +
Keine Dateien vorhanden
+ ) : ( +
+ {files.map((file) => ( +
onFileSelect?.(file.id)} + > + {file.fileName} +
+ + +
+
+ ))} +
+ )} +
+ {'\uD83D\uDC64'} Pers\u00F6nlich + {'\uD83D\uDC65'} Instanz + {'\uD83C\uDFE2'} Mandant + {'\uD83D\uDD12'} Neutralisiert +
+
+ ); +}; + +export default FilesTab; diff --git a/src/components/UnifiedDataBar/SourcesTab.module.css b/src/components/UnifiedDataBar/SourcesTab.module.css new file mode 100644 index 0000000..793732c --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.module.css @@ -0,0 +1,11 @@ +.sourcesTab { + height: 100%; + overflow-y: auto; +} + +.placeholder { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; +} diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx new file mode 100644 index 0000000..403dd63 --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import styles from './SourcesTab.module.css'; + +interface SourcesTabProps { + context: UdbContext; + renderDataSourcePanel?: (instanceId: string) => React.ReactNode; +} + +const SourcesTab: React.FC = ({ context, renderDataSourcePanel }) => { + if (renderDataSourcePanel) { + return
{renderDataSourcePanel(context.instanceId)}
; + } + + return ( +
+
+ Datenquellen werden \u00FCber den Workspace verwaltet. +
+
+ ); +}; + +export default SourcesTab; diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.module.css b/src/components/UnifiedDataBar/UnifiedDataBar.module.css new file mode 100644 index 0000000..784d687 --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.module.css @@ -0,0 +1,60 @@ +.udb { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.tabBar { + display: flex; + gap: 2px; + padding: 8px 8px 0; + border-bottom: 1px solid var(--border-color, #e5e7eb); + flex-shrink: 0; +} + +.tab { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s ease; +} + +.tab:hover { + color: var(--text-primary, #111827); + background: var(--bg-hover, rgba(0, 0, 0, 0.03)); +} + +.tabActive { + color: var(--accent, #4f46e5); + border-bottom-color: var(--accent, #4f46e5); +} + +.tabContent { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +@media (prefers-color-scheme: dark) { + .tabBar { + border-bottom-color: var(--border-color-dark, #374151); + } + .tab { + color: var(--text-secondary-dark, #9ca3af); + } + .tab:hover { + color: var(--text-primary-dark, #f3f4f6); + background: rgba(255, 255, 255, 0.05); + } + .tabActive { + color: var(--accent, #818cf8); + border-bottom-color: var(--accent, #818cf8); + } +} diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx new file mode 100644 index 0000000..00ae85f --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import styles from './UnifiedDataBar.module.css'; + +export type UdbTab = 'chats' | 'files' | 'sources'; + +export interface UdbContext { + instanceId: string; + mandateId?: string; + featureInstanceId?: string; + userId?: string; +} + +interface UnifiedDataBarProps { + context: UdbContext; + activeTab?: UdbTab; + onTabChange?: (tab: UdbTab) => void; + renderChats?: (context: UdbContext) => React.ReactNode; + renderFiles?: (context: UdbContext) => React.ReactNode; + renderSources?: (context: UdbContext) => React.ReactNode; + onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + className?: string; +} + +const _TAB_LABELS: Record> = { + chats: { de: 'Chats', en: 'Chats', fr: 'Chats' }, + files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' }, + sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' }, +}; + +const UnifiedDataBar: React.FC = ({ + context, + activeTab: controlledTab, + onTabChange, + renderChats, + renderFiles, + renderSources, + className, +}) => { + const [internalTab, setInternalTab] = useState('chats'); + const currentTab = controlledTab ?? internalTab; + + const _handleTabChange = (tab: UdbTab) => { + setInternalTab(tab); + onTabChange?.(tab); + }; + + return ( +
+
+ {(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => ( + + ))} +
+
+ {currentTab === 'chats' && renderChats?.(context)} + {currentTab === 'files' && renderFiles?.(context)} + {currentTab === 'sources' && renderSources?.(context)} +
+
+ ); +}; + +export default UnifiedDataBar; diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts new file mode 100644 index 0000000..bb63a3a --- /dev/null +++ b/src/components/UnifiedDataBar/index.ts @@ -0,0 +1,6 @@ +export { default as UnifiedDataBar } from './UnifiedDataBar'; +export type { UdbContext, UdbTab } from './UnifiedDataBar'; +export { default as ChatsTab } from './ChatsTab'; +export { default as FilesTab } from './FilesTab'; +export { default as SourcesTab } from './SourcesTab'; +export { useUdlContext } from './useUdlContext'; diff --git a/src/components/UnifiedDataBar/useUdlContext.ts b/src/components/UnifiedDataBar/useUdlContext.ts new file mode 100644 index 0000000..7bb2f89 --- /dev/null +++ b/src/components/UnifiedDataBar/useUdlContext.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; + +/** + * Build a UDL (Unified Data Layer) context from the current feature instance. + * Features use this to query scope-based data from the UDL + * instead of instance-scoped data silos. + * + * FeatureInstance -> UI-Scope (workflow surface) + * UDL -> Data-Scope (actual data access boundary) + */ +export function useUdlContext( + instanceId: string, + mandateId?: string, + userId?: string +): UdbContext { + return useMemo(() => ({ + instanceId, + mandateId, + featureInstanceId: instanceId, + userId, + }), [instanceId, mandateId, userId]); +} diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 9600549..a67bcac 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -279,6 +279,7 @@ export function useRegister() { interface GoogleAuthResponse { accessToken: string; tokenType: string; + isNewUser?: boolean; user: { username: string; email: string; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index f13dbc3..054eaa6 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -11,40 +11,64 @@ import { fetchStoreFeatures, activateStoreFeature, deactivateStoreFeature, + fetchUserMandates, + fetchSubscriptionInfo, type StoreFeature, + type UserMandate, + type SubscriptionInfo, } from '../api/storeApi'; import { useFeatureStore } from '../stores/featureStore'; interface UseStoreReturn { features: StoreFeature[]; + mandates: UserMandate[]; + subscriptionInfo: SubscriptionInfo | null; loading: boolean; actionLoading: string | null; error: string | null; loadStore: () => Promise; - activate: (featureCode: string) => Promise; - deactivate: (featureCode: string) => Promise; + loadSubscriptionInfo: (mandateId?: string) => Promise; + activate: (featureCode: string, mandateId?: string) => Promise; + deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise; } export function useStore(): UseStoreReturn { const [features, setFeatures] = useState([]); + const [mandates, setMandates] = useState([]); + const [subscriptionInfo, setSubscriptionInfo] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [error, setError] = useState(null); const featureStore = useFeatureStore(); + const loadSubscriptionInfo = useCallback(async (mandateId?: string) => { + try { + const info = await fetchSubscriptionInfo(mandateId); + setSubscriptionInfo(info); + } catch { + // non-critical + } + }, []); + const loadStore = useCallback(async () => { setLoading(true); setError(null); try { - const data = await fetchStoreFeatures(); + const [data, userMandates] = await Promise.all([ + fetchStoreFeatures(), + fetchUserMandates(), + ]); setFeatures(data); + setMandates(userMandates); + const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined; + await loadSubscriptionInfo(firstMandateId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Failed to load store'; setError(msg); } finally { setLoading(false); } - }, []); + }, [loadSubscriptionInfo]); useEffect(() => { loadStore(); @@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn { await loadStore(); }, [featureStore, loadStore]); - const activate = useCallback(async (featureCode: string) => { + const activate = useCallback(async (featureCode: string, mandateId?: string) => { setActionLoading(featureCode); setError(null); try { - await activateStoreFeature(featureCode); + await activateStoreFeature(featureCode, mandateId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Activation failed'; @@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - const deactivate = useCallback(async (featureCode: string) => { + const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => { setActionLoading(featureCode); setError(null); try { - await deactivateStoreFeature(featureCode); + await deactivateStoreFeature(featureCode, mandateId, instanceId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Deactivation failed'; @@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - return { features, loading, actionLoading, error, loadStore, activate, deactivate }; + return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate }; } export default useStore; diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index a66ca81..16905c5 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -242,6 +242,50 @@ text-decoration: underline; } +.ctaSection { + display: flex; + gap: 0.75rem; + width: 100%; +} + +.ctaPrimary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: none; + background-color: var(--color-secondary); + color: var(--color-text); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaPrimary:hover { + background-color: var(--color-secondary-hover); +} + +.ctaSecondary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--color-secondary); + background-color: transparent; + color: var(--color-secondary); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaSecondary:hover { + background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent); +} + button:disabled { opacity: 0.7; cursor: not-allowed; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f5673ee..6fa20a7 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { PENDING_INVITATION_KEY } from './InvitePage'; +import OnboardingWizard from '../components/OnboardingWizard'; import styles from './Login.module.css'; @@ -21,6 +22,7 @@ function Login() { const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); + const [showOnboardingWizard, setShowOnboardingWizard] = useState(false); // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); @@ -84,6 +86,10 @@ function Login() { console.log("Attempting Google login..."); const response = await loginWithGoogle(); console.log("Google login successful:", response); + if (response?.isNewUser) { + setShowOnboardingWizard(true); + return; + } handleSuccessfulLogin(); } catch (error) { console.error("Google login failed:", error); @@ -104,6 +110,21 @@ function Login() { } }; + if (showOnboardingWizard) { + return ( + { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + onDismiss={() => { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + /> + ); + } + return (
@@ -213,12 +234,22 @@ function Login() {
- Du hast noch keinen Konto? + Du hast noch kein Konto? +
+
+
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 8c71330..95051cd 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { FaEnvelopeOpenText } from 'react-icons/fa'; import styles from './Register.module.css'; @@ -27,6 +27,10 @@ function Register() { email: invitationEmail, fullName: '' }); + const [searchParams] = useSearchParams(); + const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal'; + const [companyName, setCompanyName] = useState(''); + const [companyNameFocused, setCompanyNameFocused] = useState(false); const [validationError, setValidationError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [usernameFocused, setUsernameFocused] = useState(false); @@ -40,11 +44,13 @@ function Register() { // Set page title and generate CSRF token useEffect(() => { - document.title = "PowerOn AI Platform - Registrieren"; + document.title = registrationType === 'company' + ? "PowerOn AI Platform - Unternehmenskonto erstellen" + : "PowerOn AI Platform - Kostenlos testen"; // Generate CSRF token for new security implementation generateAndStoreCSRFToken(); - }, []); + }, [registrationType]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -70,6 +76,11 @@ function Register() { return false; } + if (registrationType === 'company' && !companyName.trim()) { + setValidationError('Bitte geben Sie einen Firmennamen ein.'); + return false; + } + return true; }; @@ -97,7 +108,7 @@ function Register() { } // Username is available, proceed with registration (no password - magic link flow) - await register(formData); + await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined }); // Build success message let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; @@ -192,6 +203,22 @@ function Register() {
+ {registrationType === 'company' && ( +
+ setCompanyName(e.target.value)} + onFocus={() => setCompanyNameFocused(true)} + onBlur={() => setCompanyNameFocused(false)} + className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`} + /> + +
+ )} +
- {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"} + {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'} )} diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index 6383188..a6e1897 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -29,6 +29,52 @@ font-size: 0.9375rem; } +/* Subscription Banner */ +.subscriptionBanner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + font-size: 0.8125rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + color: var(--info-color, #1e40af); +} + +.bannerSeparator::before { + content: '|'; + margin-right: 0.25rem; + opacity: 0.4; +} + +/* Mandate Select */ +.mandateSelect { + width: 100%; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + font-size: 0.8125rem; + background: var(--surface-color, #ffffff); + color: var(--text-primary, #1a1a1a); + appearance: auto; +} + +.mandateSelect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.mandateHint { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + font-style: italic; +} + /* Grid */ .grid { display: grid; @@ -120,6 +166,49 @@ background: currentColor; } +/* Instance List */ +.instanceList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.instanceRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.instanceInfo { + min-width: 0; + overflow: hidden; +} + +.deactivateButtonSmall { + flex-shrink: 0; + padding: 0.25rem 0.625rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + color: var(--text-secondary, #666); +} + +.deactivateButtonSmall:hover:not(:disabled) { + border-color: var(--error-color, #dc2626); + color: var(--error-color, #dc2626); + background: var(--error-bg, #fef2f2); +} + +.deactivateButtonSmall:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Actions */ .cardActions { padding-top: 0.5rem; @@ -243,17 +332,35 @@ border-top-color: var(--border-dark, #333); } -:global(.dark-theme) .deactivateButton { +:global(.dark-theme) .deactivateButton, +:global(.dark-theme) .deactivateButtonSmall { border-color: var(--border-dark, #444); color: var(--text-secondary-dark, #aaa); } -:global(.dark-theme) .deactivateButton:hover:not(:disabled) { +:global(.dark-theme) .deactivateButton:hover:not(:disabled), +:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) { border-color: var(--error-color-dark, #f87171); color: var(--error-color-dark, #f87171); background: rgba(248, 113, 113, 0.1); } +:global(.dark-theme) .subscriptionBanner { + background: rgba(37, 99, 235, 0.1); + border-color: rgba(37, 99, 235, 0.25); + color: var(--primary-light, #93bbfc); +} + +:global(.dark-theme) .mandateSelect { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .mandateHint { + color: var(--text-secondary-dark, #aaa); +} + :global(.dark-theme) .error { background: var(--error-bg-dark, #450a0a); border-color: var(--error-border-dark, #991b1b); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index fd0e467..c4f901b 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -6,11 +6,11 @@ * and users get their own FeatureAccess + user-role upon activation. */ -import React from 'react'; +import React, { useState } from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; -import type { StoreFeature } from '../api/storeApi'; +import type { StoreFeature, UserMandate } from '../api/storeApi'; import styles from './Store.module.css'; const FEATURE_ICONS: Record = { @@ -62,23 +62,39 @@ function _getDescription(featureCode: string, lang: string): string { interface FeatureCardProps { feature: StoreFeature; language: string; + mandates: UserMandate[]; actionLoading: string | null; - onActivate: (code: string) => void; - onDeactivate: (code: string) => void; + onActivate: (code: string, mandateId?: string) => void; + onDeactivate: (code: string, mandateId: string, instanceId: string) => void; } const FeatureCard: React.FC = ({ feature, language, + mandates, actionLoading, onActivate, onDeactivate, }) => { + const [selectedMandateId, setSelectedMandateId] = useState(''); const isProcessing = actionLoading === feature.featureCode; const icon = FEATURE_ICONS[feature.featureCode]; + const activeInstances = feature.instances.filter(inst => inst.isActive); + const hasActive = activeInstances.length > 0; + const needsMandateSelection = mandates.length > 1; + + const _handleActivate = () => { + if (needsMandateSelection) { + onActivate(feature.featureCode, selectedMandateId || undefined); + } else if (mandates.length === 1) { + onActivate(feature.featureCode, mandates[0].id); + } else { + onActivate(feature.featureCode); + } + }; return ( -
+
{icon && {icon}}

@@ -92,36 +108,76 @@ const FeatureCard: React.FC = ({

-
- - - {feature.isActive - ? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active') - : (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')} - -
+ {activeInstances.length > 0 && ( +
+ {activeInstances.map((inst) => ( +
+
+ + + {inst.mandateName || inst.label} + +
+ +
+ ))} +
+ )} + + {activeInstances.length === 0 && ( +
+ + + {language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'} + +
+ )}
- {feature.isActive ? ( - - ) : ( - + {feature.canActivate && ( + <> + {mandates.length === 0 && ( +

+ {language === 'de' + ? 'Ein persoenliches Konto wird automatisch erstellt.' + : language === 'fr' + ? 'Un compte personnel sera cree automatiquement.' + : 'A personal account will be created automatically.'} +

+ )} + {needsMandateSelection && ( + + )} + + )}
@@ -130,7 +186,7 @@ const FeatureCard: React.FC = ({ const StorePage: React.FC = () => { const { currentLanguage } = useLanguage(); - const { features, loading, actionLoading, error, activate, deactivate } = useStore(); + const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore(); return (
@@ -145,6 +201,27 @@ const StorePage: React.FC = () => {

+ {subscriptionInfo && subscriptionInfo.plan && ( +
+ Plan: {subscriptionInfo.plan} + {subscriptionInfo.maxFeatureInstances != null && ( + + {currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances} + + )} + {subscriptionInfo.maxDataVolumeMB != null && ( + + {currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB + + )} + {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( + + {currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} + + )} +
+ )} + {error &&
{error}
} {loading ? ( @@ -164,6 +241,7 @@ const StorePage: React.FC = () => { key={feature.featureCode} feature={feature} language={currentLanguage} + mandates={mandates} actionLoading={actionLoading} onActivate={activate} onDeactivate={deactivate} diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index d078335..006680c 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -1,7 +1,56 @@ +/* Outer flex layout: UDB sidebar + main dossier */ +.dossierLayout { + display: flex; + height: calc(100vh - 140px); + overflow: hidden; +} + +.udbSidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid var(--border-color, #e0e0e0); + display: flex; + flex-direction: column; + background: var(--bg-card, #fff); + overflow: hidden; + position: relative; + transition: width 0.2s, min-width 0.2s; +} + +.udbSidebarCollapsed { + width: 36px; + min-width: 36px; +} + +.udbToggle { + position: absolute; + top: 8px; + right: 4px; + z-index: 2; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + background: var(--bg-card, #fff); + cursor: pointer; + font-size: 0.65rem; + color: var(--text-secondary, #888); + display: flex; + align-items: center; + justify-content: center; +} + +.udbToggle:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + .dossier { display: flex; flex-direction: column; - height: calc(100vh - 140px); + flex: 1; + min-width: 0; overflow: hidden; } diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index 50f0346..dd70058 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -1,48 +1,53 @@ /** * CommCoach Dossier View (Main View) * - * Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents. + * Unified view per context: Coaching session, Tasks, Sessions history, Scores. * Voice first, always with text fallback. + * Files & Sources are provided via the shared UnifiedDataBar sidebar. */ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useCommcoach } from '../../../hooks/useCommcoach'; import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { useApiRequest } from '../../../hooks/useApi'; -import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import api from '../../../api'; +import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance'; import { getDossierExportUrl, getSessionExportUrl, - getDocumentsApi, uploadDocumentApi, deleteDocumentApi, getScoreHistoryApi, getPersonasApi, - type CoachingDocument, type CoachingPersona, + type CoachingPersona, } from '../../../api/commcoachApi'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar'; +import type { UdbContext } from '../../../components/UnifiedDataBar'; import styles from './CommcoachDossierView.module.css'; import { useVoiceController } from './useVoiceController'; -type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents'; +type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores'; export const CommcoachDossierView: React.FC = () => { const coach = useCommcoach(); const { request } = useApiRequest(); const instanceId = useInstanceId(); + const mandateId = useMandateId(); const [activeTab, setActiveTab] = useState('coaching'); const [showNewContext, setShowNewContext] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newDescription, setNewDescription] = useState(''); const [newCategory, setNewCategory] = useState('custom'); + const [udbCollapsed, setUdbCollapsed] = useState(false); const [newTaskTitle, setNewTaskTitle] = useState(''); - const [documents, setDocuments] = useState([]); - const [uploading, setUploading] = useState(false); const [scoreHistory, setScoreHistory] = useState>>({}); const [personas, setPersonas] = useState([]); const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); + const _udbContext: UdbContext | null = instanceId + ? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId } + : null; + const inputRef = useRef(null); const sendMessageRef = useRef(coach.sendMessage); sendMessageRef.current = coach.sendMessage; @@ -82,27 +87,14 @@ export const CommcoachDossierView: React.FC = () => { } }, [coach.contexts, coach.selectedContextId, coach.selectContext]); - // Load documents, scores, personas when context changes + // Load scores, personas when context changes useEffect(() => { if (!instanceId || !coach.selectedContextId) return; - getDocumentsApi(request, instanceId, coach.selectedContextId) - .then(d => setDocuments(d)) - .catch(() => {}); getScoreHistoryApi(request, instanceId, coach.selectedContextId) .then(h => setScoreHistory(h)) .catch(() => {}); }, [instanceId, request, coach.selectedContextId]); - useEffect(() => { - coach.onDocumentCreatedRef.current = (doc) => { - setDocuments(prev => { - if (prev.some(d => d.id === doc.id)) return prev; - return [doc, ...prev]; - }); - }; - return () => { coach.onDocumentCreatedRef.current = null; }; - }, [coach.onDocumentCreatedRef]); - useEffect(() => { if (!instanceId) return; getPersonasApi(request, instanceId) @@ -144,46 +136,6 @@ export const CommcoachDossierView: React.FC = () => { coach.selectContext(contextId, { skipSessionResume: true }); }, [coach]); - const handleUpload = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || !instanceId || !coach.selectedContextId) return; - setUploading(true); - try { - const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file); - setDocuments(prev => [doc, ...prev]); - } catch { /* upload failed */ } finally { - setUploading(false); - e.target.value = ''; - } - }, [instanceId, coach.selectedContextId]); - - const handleDeleteDocument = useCallback(async (docId: string) => { - if (!instanceId) return; - try { - await deleteDocumentApi(request, instanceId, docId); - setDocuments(prev => prev.filter(d => d.id !== docId)); - } catch { /* delete failed */ } - }, [instanceId, request]); - - const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => { - if (!doc.fileRef) return; - try { - const response = await api.get(`/api/files/${doc.fileRef}/download`, { - responseType: 'blob', - }); - const url = window.URL.createObjectURL(response.data); - const a = document.createElement('a'); - a.href = url; - a.download = doc.fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } catch (err) { - console.error('Download failed:', err); - } - }, []); - const handleAddTask = useCallback(async () => { if (!newTaskTitle.trim()) return; await coach.addTask(newTaskTitle); @@ -195,7 +147,31 @@ export const CommcoachDossierView: React.FC = () => { } return ( -
+
+ {/* UDB Sidebar */} + {_udbContext && ( +
+ + {!udbCollapsed && ( + null} + renderFiles={(ctx) => } + renderSources={(ctx) => } + /> + )} +
+ )} + + {/* Main Content */} +
{/* Context Selector */}
{coach.contexts.map(ctx => ( @@ -286,13 +262,13 @@ export const CommcoachDossierView: React.FC = () => { {/* Tab Navigation */}
- {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( + {(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => ( ))}
@@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
)} - {/* ============================================================ */} - {/* DOCUMENTS TAB */} - {/* ============================================================ */} - {activeTab === 'documents' && ( -
-
- -
- {documents.length === 0 ? ( -
Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.
- ) : ( -
- {documents.map(doc => ( -
-
-
{doc.fileName}
-
- {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''} -
- {doc.summary &&
{doc.summary}
} -
-
- - -
-
- ))} -
- )} -
- )} )} {/* #region agent log */}
@@ -595,6 +537,7 @@ export const CommcoachDossierView: React.FC = () => {
{/* #endregion */}
+
); }; @@ -607,13 +550,12 @@ function _categoryIcon(category: string): string { return icons[category] || '*'; } -function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string { +function _tabLabel(tab: TabKey, coach: any): string { switch (tab) { case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching'; case 'tasks': return `Aufgaben (${coach.tasks.length})`; case 'sessions': return `Sessions (${coach.sessions.length})`; case 'scores': return `Bewertungen (${coach.scores.length})`; - case 'documents': return `Dokumente (${documents.length})`; } } @@ -634,12 +576,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] { return Object.values(groups); } -function _formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - function _dimensionLabel(dim: string): string { const labels: Record = { empathy: 'Einfühlungsvermögen', clarity: 'Klarheit', diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index b7af4cd..47613db 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import api from '../../../api'; import { getProfileApi, updateProfileApi, getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, @@ -14,6 +15,18 @@ import { } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; +async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise { + try { + await api.put('/api/local/voice-preferences', { + sttLanguage: lang, + ttsLanguage: lang, + ttsVoice: voice ?? null, + }); + } catch { + // Silent fallback — shared prefs sync is best-effort + } +} + export const CommcoachSettingsView: React.FC = () => { const { request } = useApiRequest(); const instanceId = useInstanceId(); @@ -88,6 +101,9 @@ export const CommcoachSettingsView: React.FC = () => { emailSummaryEnabled: emailEnabled, }); setProfile(updated); + + _syncSharedVoicePreferences(language, voiceId || undefined); + setSuccess('Einstellungen gespeichert'); setTimeout(() => setSuccess(null), 3000); } catch (err: any) { diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 0b5c330..6596820 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -147,6 +147,32 @@ export const ChatStream: React.FC = ({ charCount={(msg as any)._audioCharCount} /> )} + {msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && ( +
+ + Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'}) + +
+ {msg.documents.map((doc, idx) => ( +
+ + {doc.documentName || doc.fileName || `Dokument ${idx + 1}`} + + {doc.validationMetadata?.neutralized && ( + + neutralisiert + + )} + {doc.validationMetadata?.skipped && ( + + übersprungen + + )} +
+ ))} +
+
+ )}
)}
diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx new file mode 100644 index 0000000..a13d52d --- /dev/null +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import api from '../../../api'; + +interface NeutralizationMapping { + id: string; + originalText: string; + placeholder: string; + patternType: string; + fileId?: string; + fileName?: string; + createdAt?: string; +} + +interface NeutralizationSource { + fileId: string; + fileName: string; + neutralizationStatus: string; + mappingCount: number; +} + +interface NeutralizationPanelProps { + instanceId: string; +} + +const NeutralizationPanel: React.FC = ({ instanceId }) => { + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(null); + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadSources = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${instanceId}/files`); + const files = response.data?.data || response.data || []; + const neutralized = files + .filter((f: any) => f.neutralize) + .map((f: any) => ({ + fileId: f.id, + fileName: f.fileName || f.name || 'unknown', + neutralizationStatus: f.neutralizationStatus || f.status || 'unknown', + mappingCount: 0, + })); + setSources(neutralized); + } catch (err) { + console.error('Failed to load neutralization sources:', err); + } finally { + setLoading(false); + } + }, [instanceId]); + + const _loadMappings = useCallback(async (fileId: string) => { + try { + const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } }); + const data = response.data?.data || response.data || []; + setMappings(data.map((m: any) => ({ + id: m.id, + originalText: m.originalText || '', + placeholder: m.placeholder || m.id, + patternType: m.patternType || 'unknown', + fileId: m.fileId, + fileName: m.fileName, + createdAt: m.createdAt || m._createdAt, + }))); + } catch (err) { + console.error('Failed to load mappings:', err); + setMappings([]); + } + }, [instanceId]); + + useEffect(() => { _loadSources(); }, [_loadSources]); + + useEffect(() => { + if (selectedSource) _loadMappings(selectedSource); + }, [selectedSource, _loadMappings]); + + const _handleDeleteMapping = async (mappingId: string) => { + try { + await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`); + setMappings(prev => prev.filter(m => m.id !== mappingId)); + } catch (err) { + console.error('Failed to delete mapping:', err); + } + }; + + const _handleRetrigger = async (fileId: string) => { + try { + await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId }); + _loadSources(); + } catch (err) { + console.error('Failed to retrigger neutralization:', err); + } + }; + + const _statusBadge = (status: string) => { + const colors: Record = { + completed: { bg: '#dcfce7', text: '#166534' }, + pending: { bg: '#fef3c7', text: '#92400e' }, + failed: { bg: '#fef2f2', text: '#991b1b' }, + not_required: { bg: '#f3f4f6', text: '#6b7280' }, + }; + const c = colors[status] || colors.not_required; + return ( + + {status} + + ); + }; + + if (loading) return
Lade Neutralisierungsdaten...
; + + return ( +
+

Neutralisierung

+

+ Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings. +

+ + {sources.length === 0 ? ( +
+ Keine Datenquellen mit aktiver Neutralisierung. +
+ ) : ( +
+ {sources.map((src) => ( +
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} + > +
+
{src.fileName}
+
+ {_statusBadge(src.neutralizationStatus)} +
+
+
+ + + {selectedSource === src.fileId ? '\u25BC' : '\u25B6'} + +
+
+ ))} +
+ )} + + {selectedSource && mappings.length > 0 && ( +
+
+ Platzhalter-Mappings ({mappings.length}) +
+
+ {mappings.map((m) => ( +
+ {m.placeholder} + {'\u2192'} + {m.originalText} + {m.patternType} + +
+ ))} +
+
+ )} + + {selectedSource && mappings.length === 0 && ( +
+ Keine Mappings für diese Datenquelle. +
+ )} +
+ ); +}; + +export default NeutralizationPanel; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 0a091c4..e349e24 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -263,7 +263,10 @@ export const WorkspaceInput: React.FC = ({ }, [onPasteAsFile]); const _handlePromptDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/tree-items')) { + if ( + e.dataTransfer.types.includes('application/tree-items') || + e.dataTransfer.types.includes('application/chat-id') + ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); @@ -273,11 +276,22 @@ export const WorkspaceInput: React.FC = ({ const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); const _handlePromptDrop = useCallback((e: React.DragEvent) => { + setTreeDropOver(false); + + const chatId = e.dataTransfer.getData('application/chat-id'); + if (chatId) { + e.preventDefault(); + e.stopPropagation(); + const chatLabel = e.dataTransfer.getData('text/plain'); + const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; + setPrompt(prev => (prev ? `${prev} ${ref}` : ref)); + return; + } + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson && onTreeItemsDrop) { e.preventDefault(); e.stopPropagation(); - setTreeDropOver(false); const items: TreeItemDrop[] = JSON.parse(treeItemsJson); onTreeItemsDrop(items); } diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 0466ee8..874e807 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -19,6 +19,9 @@ import { FileBrowser } from './FileBrowser'; import { DataSourcePanel } from './DataSourcePanel'; import { FilePreview } from './FilePreview'; import { ToolActivityLog } from './ToolActivityLog'; +import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; +import type { UdbContext } from '../../../components/UnifiedDataBar'; +import OnboardingAssistant from '../../../components/OnboardingAssistant'; function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { const [width, setWidth] = useState(initialWidth); @@ -52,7 +55,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) return { width, onMouseDown: _onMouseDown }; } -type LeftTab = 'conversations' | 'files' | 'datasources'; type RightTab = 'activity' | 'preview'; interface PendingFile { @@ -78,7 +80,6 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [rightCollapsed, setRightCollapsed] = useState(false); const _leftResize = _useResizable(280, 200, 450); const _rightResize = _useResizable(320, 200, 500); - const [leftTab, setLeftTab] = useState('conversations'); const [rightTab, setRightTab] = useState('activity'); const [selectedFileId, setSelectedFileId] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); @@ -210,43 +211,42 @@ export const WorkspacePage: React.FC = ({ persistentInstance textTransform: 'uppercase' as const, }); - const _leftPanelBody = ( - <> -
- - - -
+ const _udbContext: UdbContext = { + instanceId: instanceId, + mandateId: mandateId, + featureInstanceId: instanceId, + }; -
- {leftTab === 'conversations' && ( - - )} - {leftTab === 'files' && ( - - )} - {leftTab === 'datasources' && ( - - )} -
- + const _leftPanelBody = ( + ( + + )} + renderFiles={(ctx) => ( + + )} + renderSources={(ctx) => ( + + )} + /> ); const _rightPanelBody = ( @@ -386,6 +386,11 @@ export const WorkspacePage: React.FC = ({ persistentInstance Dateien hier ablegen
)} + { @@ -69,6 +71,9 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'voice' && ( )} + {activeTab === 'neutralization' && ( + + )}
); From 9a7e3f42d2a03e878fd631b01fc98239f64baee4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 24 Mar 2026 16:39:31 +0100 Subject: [PATCH 02/22] unified data completed implementation --- src/api/commcoachApi.ts | 71 -- src/components/OnboardingAssistant.tsx | 2 +- src/pages/Settings.tsx | 608 ++++++++++-------- .../views/commcoach/CommcoachSettingsView.tsx | 163 +---- .../WorkspaceGeneralSettings.module.css | 16 + .../workspace/WorkspaceGeneralSettings.tsx | 2 +- src/pages/views/workspace/WorkspaceInput.tsx | 18 +- .../workspace/WorkspaceSettings.module.css | 173 ----- .../views/workspace/WorkspaceSettings.tsx | 280 -------- .../views/workspace/WorkspaceSettingsPage.tsx | 13 +- 10 files changed, 393 insertions(+), 953 deletions(-) create mode 100644 src/pages/views/workspace/WorkspaceGeneralSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.tsx diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index ef9b0be..47f5665 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -50,18 +50,6 @@ export interface CoachingPersona { isActive: boolean; } -export interface CoachingDocument { - id: string; - contextId: string; - fileName: string; - mimeType: string; - fileSize: number; - extractedText?: string; - summary?: string; - fileRef?: string; - createdAt?: string; -} - export interface CoachingBadge { id: string; userId: string; @@ -110,8 +98,6 @@ export interface CoachingScore { export interface CoachingUserProfile { id: string; userId: string; - preferredLanguage: string; - preferredVoice?: string; dailyReminderTime?: string; dailyReminderEnabled: boolean; emailSummaryEnabled: boolean; @@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId: return data.profile; } -// ============================================================================ -// Voice API -// ============================================================================ - -export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' }); - return data.languages || []; -} - -export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } }); - return data.voices || []; -} - -export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: { - text?: string; language?: string; voiceId?: string; -}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); - return data; -} - // ============================================================================ // Persona API (Iteration 2) // ============================================================================ @@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId: await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); } -// ============================================================================ -// Document API (Iteration 2) -// ============================================================================ - -export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' }); - return data.documents || []; -} - -export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise { - const baseURL = api.defaults.baseURL || ''; - const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`; - const formData = new FormData(); - formData.append('file', file); - - const headers: Record = {}; - const authToken = localStorage.getItem('authToken'); - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); - if (pathMatch) { - headers['X-Mandate-Id'] = pathMatch[1]; - headers['X-Instance-Id'] = pathMatch[3]; - } - if (!getCSRFToken()) generateAndStoreCSRFToken(); - addCSRFTokenToHeaders(headers); - - const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' }); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); - const data = await response.json(); - return data.document; -} - -export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise { - await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); -} - // ============================================================================ // Badge API (Iteration 2) // ============================================================================ diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx index 97bd5b0..0ebf492 100644 --- a/src/components/OnboardingAssistant.tsx +++ b/src/components/OnboardingAssistant.tsx @@ -23,7 +23,7 @@ const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; const OnboardingAssistant: React.FC = ({ instanceId, mandateId, - featureCode, + featureCode: _featureCode, onDismiss, }) => { const navigate = useNavigate(); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 12bdec7..8a8ae74 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,18 +1,31 @@ /** - * Settings Page - * - * Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). + * Settings Page — User-level settings with tabs. + * Route: /settings */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useCurrentUser, useUser } from '../hooks/useUsers'; import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; +import { useApiRequest } from '../hooks/useApi'; import styles from './Settings.module.css'; +// ============================================================================= +// TYPES +// ============================================================================= + +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; + +const _TABS: { key: SettingsTab; label: string }[] = [ + { key: 'profile', label: 'Profil' }, + { key: 'appearance', label: 'Darstellung' }, + { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'privacy', label: 'Datenschutz' }, +]; + // ============================================================================= // PROFILE EDIT MODAL // ============================================================================= @@ -27,39 +40,13 @@ interface ProfileEditModalProps { const ProfileEditModal: React.FC = ({ isOpen, onClose, userData, onSave }) => { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - - // Define editable profile fields + const profileAttributes: AttributeDefinition[] = [ - { - name: 'fullName', - type: 'string', - label: 'Vollständiger Name', - description: 'Ihr vollständiger Name', - required: false, - placeholder: 'Max Mustermann' - }, - { - name: 'email', - type: 'email', - label: 'E-Mail-Adresse', - description: 'Ihre E-Mail-Adresse für Benachrichtigungen', - required: true, - placeholder: 'name@example.com' - }, - { - name: 'language', - type: 'select', - label: 'Sprache', - description: 'Anzeigesprache der Anwendung', - required: true, - options: [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'fr', label: 'Français' } - ] - } + { name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' }, + { name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' }, + { name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] }, ]; - + const handleSubmit = async (formData: any) => { setIsSaving(true); setError(null); @@ -72,9 +59,9 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us setIsSaving(false); } }; - + if (!isOpen) return null; - + return (
e.stopPropagation()}> @@ -84,21 +71,231 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us
{error &&
{error}
} - +
); }; +// ============================================================================= +// VOICE SETTINGS TAB +// ============================================================================= + +interface VoiceMapEntry { language: string; voiceName: string; } + +const VoiceSettingsTab: React.FC = () => { + const { request } = useApiRequest(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [sttLanguage, setSttLanguage] = useState('de-DE'); + const [languages, setLanguages] = useState([]); + const [voiceMap, setVoiceMap] = useState([]); + + const [addLanguage, setAddLanguage] = useState('de-DE'); + const [addVoices, setAddVoices] = useState([]); + const [addVoiceName, setAddVoiceName] = useState(''); + const [loadingVoices, setLoadingVoices] = useState(false); + + const _loadSettings = useCallback(async () => { + setLoading(true); + try { + const [prefsData, languagesData] = await Promise.all([ + request({ url: '/api/local/voice-preferences', method: 'get' }), + request({ url: '/api/local/voice/languages', method: 'get' }), + ]); + + const langList = (languagesData as any)?.languages || []; + setLanguages(langList); + + const prefs = prefsData as any; + setSttLanguage(prefs?.sttLanguage || 'de-DE'); + + const map: Record = prefs?.ttsVoiceMap || {}; + const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ + language: lang, + voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', + })); + setVoiceMap(entries); + } catch (err: any) { + setError(err.message || 'Fehler beim Laden der Voice-Einstellungen'); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _loadSettings(); }, [_loadSettings]); + + const _loadVoicesForLanguage = useCallback(async (lang: string) => { + setLoadingVoices(true); + try { + const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } }); + setAddVoices((result as any)?.voices || []); + setAddVoiceName(''); + } catch { setAddVoices([]); } + finally { setLoadingVoices(false); } + }, [request]); + + useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); + + const _handleAddEntry = useCallback(() => { + if (!addLanguage) return; + const exists = voiceMap.some(e => e.language === addLanguage); + if (exists) { + setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e)); + } else { + setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); + } + setAddVoiceName(''); + }, [addLanguage, addVoiceName, voiceMap]); + + const _handleRemoveEntry = useCallback((lang: string) => { + setVoiceMap(prev => prev.filter(e => e.language !== lang)); + }, []); + + const _handleSave = useCallback(async () => { + setSaving(true); + setError(null); + setSuccess(null); + try { + const mapObj: Record = {}; + voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; }); + await request({ + url: '/api/local/voice-preferences', + method: 'put', + data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj }, + }); + setSuccess('Einstellungen gespeichert'); + setTimeout(() => setSuccess(null), 3000); + await _loadSettings(); + } catch (err: any) { + setError(err.message || 'Fehler beim Speichern'); + } finally { + setSaving(false); + } + }, [request, voiceMap, sttLanguage, _loadSettings]); + + const _handleTestVoice = useCallback(async (lang: string, voice: string) => { + setTesting(lang); + try { + const result: any = await request({ + url: '/api/local/voice/test', + method: 'post', + data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, + }); + if (result?.success && result?.audio) { + const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); + audio.play(); + } + } catch { setError('Stimmtest fehlgeschlagen'); } + finally { setTesting(null); } + }, [request]); + + const _getLanguageName = useCallback((code: string) => { + const found = languages.find((l: any) => (l.code || l) === code); + return found?.name || found?.code || code; + }, [languages]); + + const _defaultLangs = [ + { code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' }, + { code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' }, + { code: 'es-ES', name: 'Espanol' }, + ]; + const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; + + if (loading) return
Einstellungen werden geladen...
; + + return ( + <> + {error &&
{error}
} + {success &&
{success}
} + +
+

STT-Sprache (Spracheingabe)

+
+
+ +

Wird fuer die Sprache-zu-Text-Erkennung verwendet.

+
+
+ +
+
+
+ +
+

TTS-Stimmen (Sprachausgabe)

+

+ Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. +

+ + {voiceMap.length === 0 ? ( +
+ Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. +
+ ) : ( + + + + {voiceMap.map(entry => ( + + + + + + + ))} + +
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} + + + +
+ )} + +
+
+ + +
+
+ + +
+ + +
+
+ + + + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -107,266 +304,135 @@ export const SettingsPage: React.FC = () => { const { currentLanguage, setLanguage } = useLanguage(); const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { updateUser } = useUser(); - - const [theme, setTheme] = useState<'light' | 'dark'>( - () => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' - ); + + const [activeTab, setActiveTab] = useState('profile'); + const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [languageError, setLanguageError] = useState(null); - - // Handle theme change + const handleThemeChange = (newTheme: 'light' | 'dark') => { setTheme(newTheme); localStorage.setItem('theme', newTheme); - - if (newTheme === 'dark') { - document.documentElement.classList.add('dark-theme'); - document.documentElement.classList.remove('light-theme'); - } else { - document.documentElement.classList.add('light-theme'); - document.documentElement.classList.remove('dark-theme'); - } + if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); } + else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); } document.documentElement.setAttribute('data-theme', newTheme); }; - - // Handle language change - save to backend and update cache + const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => { if (!currentUser?.id || !currentUser?.username) return; - setIsSavingLanguage(true); setLanguageError(null); - try { - // 1. Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: currentUser.email, - fullName: currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // 2. Save to backend - await updateUser(currentUser.id, userUpdateData); - - // 3. Update sessionStorage cache + await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ ...cachedUser, language: newLanguage }); - } - - // 4. Update UI language context + if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage }); setLanguage(newLanguage); - - // 5. Dispatch event to notify other components window.dispatchEvent(new CustomEvent('userInfoUpdated')); - - console.log('Language updated successfully to:', newLanguage); - } catch (err: any) { - console.error('Failed to update language:', err); - setLanguageError('Sprache konnte nicht gespeichert werden'); - } finally { - setIsSavingLanguage(false); - } + } catch { setLanguageError('Sprache konnte nicht gespeichert werden'); } + finally { setIsSavingLanguage(false); } }, [currentUser, updateUser, setLanguage]); - - // Handle profile save + const handleProfileSave = useCallback(async (formData: any) => { if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet'); - - // Get the new language (from form or current user) const newLanguage = formData.language || currentUser.language || 'de'; - - // Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: formData.email || currentUser.email, - fullName: formData.fullName || currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // Update user via API - const updatedUser = await updateUser(currentUser.id, userUpdateData); - - // Update sessionStorage cache + const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ - ...cachedUser, - fullName: updatedUser.fullName || cachedUser.fullName, - email: updatedUser.email || cachedUser.email, - language: newLanguage - }); - } - - // Update UI language if changed - if (newLanguage !== currentLanguage) { - setLanguage(newLanguage as 'de' | 'en' | 'fr'); - } - - // Refetch user data - if (refetchUser) { - await refetchUser(); - } - - // Dispatch event to notify other components (e.g., sidebar) + if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage }); + if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr'); + if (refetchUser) await refetchUser(); window.dispatchEvent(new CustomEvent('userInfoUpdated')); - }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); - + return (

Einstellungen

-

Persönliche Einstellungen und Präferenzen

+

Persoenliche Einstellungen und Praeferenzen

- + + +
- {/* Darstellung */} -
-

Darstellung

- -
-
- -

- Wähle zwischen hellem und dunklem Design. -

-
-
-
- - + {activeTab === 'profile' && ( + <> +
+

Konto

+
+
+ +

Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.

+
+
+ +
+
+ {currentUser && ( +
+
Benutzername{currentUser.username}
+
Name{currentUser.fullName || '-'}
+
E-Mail{currentUser.email || '-'}
+
+ )} +
+
+

Ueber

+
+
Version2.0.0
+
Build2026.03.23
+
+
+ + )} + + {activeTab === 'appearance' && ( +
+

Darstellung

+
+

Waehlen Sie zwischen hellem und dunklem Design.

+
+
+ + +
-
- -
-
- -

- Wähle die Anzeigesprache der Anwendung. - {languageError && {languageError}} -

-
-
- - {isSavingLanguage && Speichern...} -
-
-
- - {/* Konto */} -
-

Konto

- -
-
- -

- Ändere deinen Namen und E-Mail-Adresse. -

-
-
- -
-
- - {/* Current user info display */} - {currentUser && ( -
-
- Benutzername - {currentUser.username} -
-
- Name - {currentUser.fullName || '-'} -
-
- E-Mail - {currentUser.email || '-'} +
+

Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && {languageError}}

+
+ + {isSavingLanguage && Speichern...}
- )} -
- - {/* Datenschutz */} -
-

Datenschutz

- -
-
- -

- Data export, portability and account deletion. -

+
+ )} + + {activeTab === 'voice' && } + + {activeTab === 'privacy' && ( +
+

Datenschutz

+
+

Datenexport, Portabilitaet und Kontoloeschung.

+
GDPR oeffnen
-
- - Open GDPR page - -
-
- - - {/* Info */} -
-

Über

- -
-
- Version - 2.0.0 -
-
- Build - 2026.01.20 -
-
-
+ + )} - - {/* Profile Edit Modal */} - setIsProfileModalOpen(false)} - userData={currentUser} - onSave={handleProfileSave} - /> + + setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} /> ); }; diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index 47613db..f7c056a 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -1,47 +1,30 @@ /** * CommCoach Settings View - * - * User profile settings: voice preferences, reminders, email notifications. + * + * Coaching-specific settings: reminders, email notifications, stats. + * Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import api from '../../../api'; import { getProfileApi, updateProfileApi, - getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, type CoachingUserProfile, } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; -async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise { - try { - await api.put('/api/local/voice-preferences', { - sttLanguage: lang, - ttsLanguage: lang, - ttsVoice: voice ?? null, - }); - } catch { - // Silent fallback — shared prefs sync is best-effort - } -} - export const CommcoachSettingsView: React.FC = () => { const { request } = useApiRequest(); const instanceId = useInstanceId(); const [profile, setProfile] = useState(null); - const [languages, setLanguages] = useState([]); - const [voices, setVoices] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [language, setLanguage] = useState('de-DE'); - const [voiceId, setVoiceId] = useState(''); const [reminderEnabled, setReminderEnabled] = useState(false); const [reminderTime, setReminderTime] = useState('09:00'); const [emailEnabled, setEmailEnabled] = useState(true); @@ -51,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => { const loadData = async () => { setLoading(true); try { - const [profileData, languagesData] = await Promise.all([ - getProfileApi(request, instanceId), - getVoiceLanguagesApi(request, instanceId), - ]); + const profileData = await getProfileApi(request, instanceId); setProfile(profileData); - setLanguages(languagesData || []); - if (profileData) { - setLanguage(profileData.preferredLanguage || 'de-DE'); - setVoiceId(profileData.preferredVoice || ''); setReminderEnabled(profileData.dailyReminderEnabled || false); setReminderTime(profileData.dailyReminderTime || '09:00'); setEmailEnabled(profileData.emailSummaryEnabled !== false); } - - const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE'); - setVoices(voicesData || []); } catch (err: any) { setError(err.message || 'Fehler beim Laden'); } finally { @@ -77,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => { loadData(); }, [request, instanceId]); - const handleLanguageChange = useCallback(async (newLang: string) => { - setLanguage(newLang); - if (!instanceId) return; - try { - const voicesData = await getVoiceVoicesApi(request, instanceId, newLang); - setVoices(voicesData || []); - setVoiceId(''); - } catch { /* ignore */ } - }, [request, instanceId]); - const handleSave = useCallback(async () => { if (!instanceId) return; setSaving(true); @@ -94,16 +57,11 @@ export const CommcoachSettingsView: React.FC = () => { setSuccess(null); try { const updated = await updateProfileApi(request, instanceId, { - preferredLanguage: language, - preferredVoice: voiceId || null, dailyReminderEnabled: reminderEnabled, dailyReminderTime: reminderTime, emailSummaryEnabled: emailEnabled, }); setProfile(updated); - - _syncSharedVoicePreferences(language, voiceId || undefined); - setSuccess('Einstellungen gespeichert'); setTimeout(() => setSuccess(null), 3000); } catch (err: any) { @@ -111,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => { } finally { setSaving(false); } - }, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]); - - const handleTestVoice = useCallback(async () => { - if (!instanceId) return; - setTesting(true); - try { - const result = await testVoiceApi(request, instanceId, { - language, - voiceId: voiceId || undefined, - }); - if (result.success && result.audio) { - const audioData = `data:audio/mp3;base64,${result.audio}`; - const audio = new Audio(audioData); - audio.play(); - } - } catch (err: any) { - setError('Sprachtest fehlgeschlagen'); - } finally { - setTesting(false); - } - }, [request, instanceId, language, voiceId]); + }, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]); if (loading) { return
Einstellungen werden geladen...
; @@ -144,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => { {error &&
{error}
} {success &&
{success}
} - {/* Voice Settings */}
-

Sprache und Stimme

- -
- - -
- -
- -
- - -
-
+

Stimme & Sprache

+

+ Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert. +

+ {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}> + Benutzereinstellungen oeffnen (Tab "Stimme & Sprache") +
- {/* Reminder Settings */}

Erinnerungen

-
- {reminderEnabled && (
- setReminderTime(e.target.value)} - /> + setReminderTime(e.target.value)} />
)} -
- {/* Stats */} {profile && (

Statistik

-
- {profile.totalSessions} - Sessions gesamt -
-
- {profile.totalMinutes} - Minuten gesamt -
-
- {profile.streakDays} - Aktueller Streak -
-
- {profile.longestStreak} - Laengster Streak -
+
{profile.totalSessions}Sessions gesamt
+
{profile.totalMinutes}Minuten gesamt
+
{profile.streakDays}Aktueller Streak
+
{profile.longestStreak}Laengster Streak
)} diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.module.css b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css new file mode 100644 index 0000000..a44d272 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css @@ -0,0 +1,16 @@ +.settings { padding: 1rem; max-width: 640px; } +.heading { margin: 0 0 1.5rem; font-size: 1.25rem; font-weight: 600; color: var(--text-primary, #1a1a1a); } +.loading { padding: 2rem; text-align: center; color: #999; } +.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.section { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.5rem; } +.sectionTitle { margin: 0 0 1rem; font-size: 0.95rem; font-weight: 600; } +.field { margin-bottom: 1rem; } +.label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.35rem; } +.input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #d0d0d0); border-radius: 6px; font-size: 0.875rem; background: var(--bg-primary, #fff); color: var(--text-primary, #1a1a1a); } +.input:focus { outline: none; border-color: var(--primary-color, #2563eb); box-shadow: 0 0 0 2px rgba(37,99,235,0.1); } +.removeBtn { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0.5rem; } +.removeBtn:hover { text-decoration: underline; } +.saveBtn { padding: 0.625rem 1.5rem; background: var(--primary-color, #2563eb); color: #fff; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; } +.saveBtn:hover { opacity: 0.9; } +.saveBtn:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx index 901a8fb..8680b3e 100644 --- a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; +import styles from './WorkspaceGeneralSettings.module.css'; interface GeneralSettingsProps { instanceId: string; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index e349e24..9d91a75 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -78,8 +78,9 @@ export const WorkspaceInput: React.FC = ({ const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [treeDropOver, setTreeDropOver] = useState(false); const [voiceActive, setVoiceActive] = useState(false); - const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); + const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [showLangPicker, setShowLangPicker] = useState(false); + const _sttPrefsLoaded = useRef(false); const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); @@ -89,8 +90,13 @@ export const WorkspaceInput: React.FC = ({ const currentInterimRef = useRef(''); useEffect(() => { - localStorage.setItem('workspace_stt_lang', voiceLanguage); - }, [voiceLanguage]); + if (_sttPrefsLoaded.current) return; + _sttPrefsLoaded.current = true; + fetch('/api/local/voice-preferences', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); }) + .catch(() => {}); + }, []); const _extractFileRefs = useCallback( (text: string): string[] => { @@ -679,7 +685,11 @@ export const WorkspaceInput: React.FC = ({ {_STT_LANGUAGES.map(lang => (
{ setVoiceLanguage(lang.code); setShowLangPicker(false); }} + onClick={() => { + setVoiceLanguage(lang.code); + setShowLangPicker(false); + fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); + }} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 13, background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent', diff --git a/src/pages/views/workspace/WorkspaceSettings.module.css b/src/pages/views/workspace/WorkspaceSettings.module.css deleted file mode 100644 index 8138b1e..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.module.css +++ /dev/null @@ -1,173 +0,0 @@ -.settings { - padding: 1rem; - max-width: 600px; -} - -.heading { - font-size: 1.2rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary, #333); -} - -.loading { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.error { - padding: 0.5rem 0.75rem; - background: #fde8e8; - color: var(--color-error, #d32f2f); - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.success { - padding: 0.5rem 0.75rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.section { - margin-bottom: 2rem; -} - -.sectionTitle { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text-primary, #333); -} - -.field { - margin-bottom: 0.75rem; -} - -.label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 0.3rem; - color: var(--text-primary, #333); -} - -.select, .input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color, #ddd); - border-radius: 6px; - font-size: 0.9rem; - background: var(--bg-input, #fff); - color: var(--text-primary, #333); -} - -.voiceRow { - display: flex; - gap: 0.5rem; -} - -.voiceRow .select { - flex: 1; -} - -.testBtn, .addBtn, .removeBtn { - padding: 0.5rem 1rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; -} - -.testBtn:hover:not(:disabled), -.addBtn:hover:not(:disabled) { filter: brightness(1.08); } - -.testBtn:disabled, -.addBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.removeBtn { - background: transparent; - color: var(--color-error, #d32f2f); - padding: 0.3rem 0.6rem; - font-size: 0.8rem; - border: 1px solid var(--color-error, #d32f2f); -} - -.removeBtn:hover { background: #fde8e8; } - -.voiceTable { - width: 100%; - border-collapse: collapse; - margin-top: 0.75rem; -} - -.voiceTable th, -.voiceTable td { - text-align: left; - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - font-size: 0.85rem; -} - -.voiceTable th { - font-weight: 600; - color: var(--text-secondary, #666); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.emptyHint { - color: var(--text-secondary, #999); - font-size: 0.85rem; - font-style: italic; - padding: 0.5rem 0; -} - -.saveBtn { - width: 100%; - padding: 0.6rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; -} - -.saveBtn:hover:not(:disabled) { filter: brightness(1.08); } -.saveBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.backBtn { - background: none; - border: none; - cursor: pointer; - font-size: 0.85rem; - color: var(--primary-color, #1976d2); - padding: 0; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 4px; -} - -.backBtn:hover { text-decoration: underline; } diff --git a/src/pages/views/workspace/WorkspaceSettings.tsx b/src/pages/views/workspace/WorkspaceSettings.tsx deleted file mode 100644 index 11e9c46..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * WorkspaceSettings -- Voice preferences per language. - * - * Allows the user to configure a preferred voice for each TTS language. - * Language detection is automatic; this page lets users override the - * default Google Cloud voice for specific languages. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; - -interface VoiceMapEntry { - language: string; - voiceName: string; -} - -interface WorkspaceSettingsProps { - instanceId: string; -} - -export const WorkspaceSettings: React.FC = ({ instanceId }) => { - const { request } = useApiRequest(); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(null); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const [languages, setLanguages] = useState([]); - const [voiceMap, setVoiceMap] = useState([]); - - const [addLanguage, setAddLanguage] = useState('de-DE'); - const [addVoices, setAddVoices] = useState([]); - const [addVoiceName, setAddVoiceName] = useState(''); - const [loadingVoices, setLoadingVoices] = useState(false); - - const _loadSettings = useCallback(async () => { - if (!instanceId) return; - setLoading(true); - try { - const [settingsData, languagesData] = await Promise.all([ - request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }), - request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); - - const map: Record = (settingsData as any)?.ttsVoiceMap || {}; - const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ - language: lang, - voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', - })); - setVoiceMap(entries); - } catch (err: any) { - setError(err.message || 'Fehler beim Laden der Einstellungen'); - } finally { - setLoading(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadSettings(); }, [_loadSettings]); - - const _loadVoicesForLanguage = useCallback(async (lang: string) => { - if (!instanceId) return; - setLoadingVoices(true); - try { - const result = await request({ - url: `/api/workspace/${instanceId}/voice/voices`, - method: 'get', - params: { language: lang }, - }); - setAddVoices((result as any)?.voices || []); - setAddVoiceName(''); - } catch { - setAddVoices([]); - } finally { - setLoadingVoices(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); - - const _handleAddEntry = useCallback(() => { - if (!addLanguage) return; - const exists = voiceMap.some(e => e.language === addLanguage); - if (exists) { - setVoiceMap(prev => prev.map(e => - e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e - )); - } else { - setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); - } - setAddVoiceName(''); - }, [addLanguage, addVoiceName, voiceMap]); - - const _handleRemoveEntry = useCallback((lang: string) => { - setVoiceMap(prev => prev.filter(e => e.language !== lang)); - }, []); - - const _handleSave = useCallback(async () => { - if (!instanceId) return; - setSaving(true); - setError(null); - setSuccess(null); - try { - const mapObj: Record = {}; - voiceMap.forEach(e => { - mapObj[e.language] = { voiceName: e.voiceName || '' }; - }); - const putResult = await request({ - url: `/api/workspace/${instanceId}/settings/voice`, - method: 'put', - data: { ttsVoiceMap: mapObj }, - }); - if ((putResult as any)?.error) { - setError((putResult as any).error); - return; - } - setSuccess('Einstellungen gespeichert'); - setTimeout(() => setSuccess(null), 3000); - await _loadSettings(); - } catch (err: any) { - setError(err.message || 'Fehler beim Speichern'); - } finally { - setSaving(false); - } - }, [request, instanceId, voiceMap]); - - const _handleTestVoice = useCallback(async (lang: string, voice: string) => { - if (!instanceId) return; - setTesting(lang); - try { - const result: any = await request({ - url: `/api/workspace/${instanceId}/voice/test`, - method: 'post', - data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, - }); - if (result?.success && result?.audio) { - const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); - audio.play(); - } - } catch { - setError('Stimmtest fehlgeschlagen'); - } finally { - setTesting(null); - } - }, [request, instanceId]); - - const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - if (loading) { - return
Einstellungen werden geladen...
; - } - - const _defaultLangs = [ - { code: 'de-DE', name: 'Deutsch' }, - { code: 'en-US', name: 'English (US)' }, - { code: 'fr-FR', name: 'Francais' }, - { code: 'it-IT', name: 'Italiano' }, - { code: 'es-ES', name: 'Espanol' }, - ]; - const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; - - return ( -
-

Stimmeneinstellungen

- - {error &&
{error}
} - {success &&
{success}
} - -
-

Konfigurierte Stimmen pro Sprache

-

- Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. -

- - {voiceMap.length === 0 ? ( -
- Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. -
- ) : ( - - - - - - - - - - - {voiceMap.map(entry => ( - - - - - - - ))} - -
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} - - - -
- )} -
- -
-

Stimme hinzufuegen / aendern

- -
- - -
- -
- -
- - -
-
- - -
- - -
- ); -}; - -export default WorkspaceSettings; diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 6a22317..8f25088 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -1,21 +1,19 @@ /** * WorkspaceSettingsPage -- Tabbed settings for the AI Workspace. * - * First tab: Voice / Language (WorkspaceSettings). - * Additional tabs can be added here as needed. + * Tabs: General settings, Neutralization. + * Voice settings are now in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState } from 'react'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { WorkspaceSettings } from './WorkspaceSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; import NeutralizationPanel from './NeutralizationPanel'; -type SettingsTab = 'general' | 'voice' | 'neutralization'; +type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'voice', label: 'Sprache & Stimme' }, { key: 'neutralization', label: 'Neutralisierung' }, ]; @@ -26,7 +24,7 @@ export const WorkspaceSettingsPage: React.FC = () => { if (!instanceId) { return (
- Keine Workspace-Instanz ausgewählt. + Keine Workspace-Instanz ausgewaehlt.
); } @@ -68,9 +66,6 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'general' && ( )} - {activeTab === 'voice' && ( - - )} {activeTab === 'neutralization' && ( )} From 0f791a53fbf5f02131d6607bac06497fe94727a0 Mon Sep 17 00:00:00 2001 From: idittrich-valueon Date: Wed, 25 Mar 2026 09:39:19 +0100 Subject: [PATCH 03/22] next version of visual workflow editor with Clickup Connector --- Untitled | 1 + src/api/automation2Api.ts | 188 +- src/api/connectionApi.ts | 6 +- .../Automation2FlowEditor.module.css | 499 ---- .../Automation2FlowEditor/NodeConfigPanel.tsx | 70 - .../configs/AiNodeConfig.tsx | 68 - .../configs/FormNodeConfig.tsx | 126 - .../configs/ReviewNodeConfig.tsx | 17 - .../configs/SharePointNodeConfig.tsx | 245 -- .../configs/UploadNodeConfig.tsx | 37 - .../Automation2FlowEditor/configs/types.ts | 16 - .../Automation2FlowEditor/constants.ts | 38 - .../context/Automation2DataFlowContext.tsx | 66 + .../editor/Automation2FlowEditor.module.css | 1453 +++++++++++ .../{ => editor}/Automation2FlowEditor.tsx | 228 +- .../{ => editor}/CanvasHeader.tsx | 17 +- .../{ => editor}/FlowCanvas.tsx | 443 +++- .../editor/NodeConfigPanel.tsx | 120 + .../{ => editor}/NodeListItem.tsx | 6 +- .../{ => editor}/NodeSidebar.tsx | 15 +- .../editor/WorkflowConfigurationModal.tsx | 115 + src/components/Automation2FlowEditor/index.ts | 20 +- .../nodes/configs/AiNodeConfig.tsx | 90 + .../configs/ApprovalNodeConfig.tsx | 0 .../nodes/configs/ClickUpNodeConfig.tsx | 2221 +++++++++++++++++ .../{ => nodes}/configs/CommentNodeConfig.tsx | 0 .../configs/ConfirmationNodeConfig.tsx | 0 .../{ => nodes}/configs/EmailNodeConfig.tsx | 2 +- .../nodes/configs/FileCreateNodeConfig.tsx | 121 + .../nodes/configs/ReviewNodeConfig.tsx | 18 + .../configs/SelectionNodeConfig.tsx | 0 .../nodes/configs/SharePointNodeConfig.tsx | 340 +++ .../nodes/configs/UploadNodeConfig.tsx | 80 + .../{ => nodes}/configs/index.ts | 25 +- .../nodes/configs/types.ts | 1 + .../nodes/form/FormNodeConfig.tsx | 228 ++ .../Automation2FlowEditor/nodes/form/index.ts | 1 + .../nodes/ifElse/IfElseNodeConfig.tsx | 151 ++ .../nodes/ifElse/index.ts | 1 + .../nodes/loop/LoopNodeConfig.tsx | 26 + .../Automation2FlowEditor/nodes/loop/index.ts | 1 + .../nodes/runtime/fileTypeMimeMapping.ts | 98 + .../nodes/runtime/scheduleCron.ts | 296 +++ .../nodes/runtime/workflowStartSync.ts | 222 ++ .../nodes/shared/DataPicker.tsx | 126 + .../nodes/shared/DynamicValueField.tsx | 114 + .../nodes/shared/HybridStaticRefField.tsx | 109 + .../nodes/shared/LoopItemsSelect.tsx | 211 ++ .../nodes/shared/RefSourceSelect.tsx | 405 +++ .../{ => nodes/shared}/categoryIcons.tsx | 4 +- .../nodes/shared/clickupFormSync.ts | 277 ++ .../nodes/shared/conditionOperators.ts | 66 + .../nodes/shared/constants.ts | 20 + .../nodes/shared/dataFlowGraph.ts | 97 + .../nodes/shared/dataRef.ts | 91 + .../{ => nodes/shared}/graphUtils.ts | 21 +- .../nodes/shared/outputPreviewRegistry.ts | 153 ++ .../nodes/shared/types.ts | 28 + .../{ => nodes/shared}/utils.ts | 0 .../nodes/start/FormStartNodeConfig.tsx | 122 + .../nodes/start/ScheduleStartNodeConfig.tsx | 435 ++++ .../nodes/start/StartNodeConfig.tsx | 40 + .../nodes/start/index.ts | 3 + .../nodes/switch/SwitchNodeConfig.tsx | 247 ++ .../nodes/switch/index.ts | 1 + .../FolderTree/SharepointBrowseTree.tsx | 41 +- src/hooks/useConnections.ts | 96 +- src/hooks/useFiles.ts | 7 +- src/pages/admin/Admin.module.css | 32 +- src/pages/basedata/ConnectionsPage.tsx | 41 +- .../automation2/Automation2WorkflowsPage.tsx | 90 +- .../Automation2WorkflowsTasks.module.css | 189 +- .../Automation2WorkflowsTasksPage.tsx | 523 +++- src/pages/views/workspace/DataSourcePanel.tsx | 1 + src/pages/views/workspace/FilePreview.tsx | 14 +- 75 files changed, 9926 insertions(+), 1394 deletions(-) create mode 100644 Untitled delete mode 100644 src/components/Automation2FlowEditor/Automation2FlowEditor.module.css delete mode 100644 src/components/Automation2FlowEditor/NodeConfigPanel.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/AiNodeConfig.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/ReviewNodeConfig.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/SharePointNodeConfig.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/UploadNodeConfig.tsx delete mode 100644 src/components/Automation2FlowEditor/configs/types.ts delete mode 100644 src/components/Automation2FlowEditor/constants.ts create mode 100644 src/components/Automation2FlowEditor/context/Automation2DataFlowContext.tsx create mode 100644 src/components/Automation2FlowEditor/editor/Automation2FlowEditor.module.css rename src/components/Automation2FlowEditor/{ => editor}/Automation2FlowEditor.tsx (58%) rename src/components/Automation2FlowEditor/{ => editor}/CanvasHeader.tsx (87%) rename src/components/Automation2FlowEditor/{ => editor}/FlowCanvas.tsx (57%) create mode 100644 src/components/Automation2FlowEditor/editor/NodeConfigPanel.tsx rename src/components/Automation2FlowEditor/{ => editor}/NodeListItem.tsx (87%) rename src/components/Automation2FlowEditor/{ => editor}/NodeSidebar.tsx (87%) create mode 100644 src/components/Automation2FlowEditor/editor/WorkflowConfigurationModal.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/configs/AiNodeConfig.tsx rename src/components/Automation2FlowEditor/{ => nodes}/configs/ApprovalNodeConfig.tsx (100%) create mode 100644 src/components/Automation2FlowEditor/nodes/configs/ClickUpNodeConfig.tsx rename src/components/Automation2FlowEditor/{ => nodes}/configs/CommentNodeConfig.tsx (100%) rename src/components/Automation2FlowEditor/{ => nodes}/configs/ConfirmationNodeConfig.tsx (100%) rename src/components/Automation2FlowEditor/{ => nodes}/configs/EmailNodeConfig.tsx (99%) create mode 100644 src/components/Automation2FlowEditor/nodes/configs/FileCreateNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/configs/ReviewNodeConfig.tsx rename src/components/Automation2FlowEditor/{ => nodes}/configs/SelectionNodeConfig.tsx (100%) create mode 100644 src/components/Automation2FlowEditor/nodes/configs/SharePointNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/configs/UploadNodeConfig.tsx rename src/components/Automation2FlowEditor/{ => nodes}/configs/index.ts (61%) create mode 100644 src/components/Automation2FlowEditor/nodes/configs/types.ts create mode 100644 src/components/Automation2FlowEditor/nodes/form/FormNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/form/index.ts create mode 100644 src/components/Automation2FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/ifElse/index.ts create mode 100644 src/components/Automation2FlowEditor/nodes/loop/LoopNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/loop/index.ts create mode 100644 src/components/Automation2FlowEditor/nodes/runtime/fileTypeMimeMapping.ts create mode 100644 src/components/Automation2FlowEditor/nodes/runtime/scheduleCron.ts create mode 100644 src/components/Automation2FlowEditor/nodes/runtime/workflowStartSync.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/DataPicker.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/shared/DynamicValueField.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/shared/HybridStaticRefField.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/shared/LoopItemsSelect.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/shared/RefSourceSelect.tsx rename src/components/Automation2FlowEditor/{ => nodes/shared}/categoryIcons.tsx (79%) create mode 100644 src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/conditionOperators.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/constants.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/dataFlowGraph.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/dataRef.ts rename src/components/Automation2FlowEditor/{ => nodes/shared}/graphUtils.ts (80%) create mode 100644 src/components/Automation2FlowEditor/nodes/shared/outputPreviewRegistry.ts create mode 100644 src/components/Automation2FlowEditor/nodes/shared/types.ts rename src/components/Automation2FlowEditor/{ => nodes/shared}/utils.ts (100%) create mode 100644 src/components/Automation2FlowEditor/nodes/start/FormStartNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/start/StartNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/start/index.ts create mode 100644 src/components/Automation2FlowEditor/nodes/switch/SwitchNodeConfig.tsx create mode 100644 src/components/Automation2FlowEditor/nodes/switch/index.ts diff --git a/Untitled b/Untitled new file mode 100644 index 0000000..2f259b7 --- /dev/null +++ b/Untitled @@ -0,0 +1 @@ +s \ No newline at end of file diff --git a/src/api/automation2Api.ts b/src/api/automation2Api.ts index f215f7e..976c7d8 100644 --- a/src/api/automation2Api.ts +++ b/src/api/automation2Api.ts @@ -27,6 +27,8 @@ export interface NodeType { parameters: NodeTypeParameter[]; inputs: number; outputs: number; + /** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */ + outputLabels?: string[]; executor: string; meta?: { icon?: string; @@ -76,11 +78,24 @@ export interface ExecuteGraphResponse { nodeId?: string; } +/** Entry point / start configured outside the canvas (manual, form, schedule, …) */ +export interface WorkflowEntryPoint { + id: string; + kind: string; + category: 'on_demand' | 'always_on'; + enabled: boolean; + title: Record | string; + description?: Record; + config: Record; +} + export interface Automation2Workflow { id: string; label: string; graph: Automation2Graph; active?: boolean; + /** Entry points (Starts) — how this workflow may be invoked */ + invocations?: WorkflowEntryPoint[]; /** Enriched: run count */ runCount?: number; /** Enriched: has active (running/paused) run */ @@ -128,22 +143,36 @@ export async function fetchNodeTypes( * Execute an automation2 graph. * POST /api/automation2/{instanceId}/execute */ +export interface ExecuteGraphOptions { + /** Use a configured start on the saved workflow */ + entryPointId?: string; + /** Full run envelope (overrides entry point mapping) */ + runEnvelope?: Record; + /** Merged into envelope.payload */ + payload?: Record; +} + export async function executeGraph( request: ApiRequestFunction, instanceId: string, graph: Automation2Graph, - workflowId?: string + workflowId?: string, + options?: ExecuteGraphOptions ): Promise { console.log( `${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`, - { nodes: graph.nodes, connections: graph.connections } + { nodes: graph.nodes, connections: graph.connections, options } ); const start = performance.now(); try { + const data: Record = { graph, workflowId }; + if (options?.entryPointId) data.entryPointId = options.entryPointId; + if (options?.runEnvelope) data.runEnvelope = options.runEnvelope; + if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload; const result = await request({ url: `/api/automation2/${instanceId}/execute`, method: 'post', - data: { graph, workflowId }, + data, }); const ms = Math.round(performance.now() - start); console.log( @@ -167,11 +196,13 @@ export async function executeGraph( export async function fetchWorkflows( request: ApiRequestFunction, - instanceId: string + instanceId: string, + params?: { active?: boolean } ): Promise { const data = await request({ url: `/api/automation2/${instanceId}/workflows`, method: 'get', + params: params?.active !== undefined ? { active: params.active } : undefined, }); return data?.workflows ?? []; } @@ -190,7 +221,7 @@ export async function fetchWorkflow( export async function createWorkflow( request: ApiRequestFunction, instanceId: string, - body: { label: string; graph: Automation2Graph } + body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] } ): Promise { return await request({ url: `/api/automation2/${instanceId}/workflows`, @@ -203,7 +234,12 @@ export async function updateWorkflow( request: ApiRequestFunction, instanceId: string, workflowId: string, - body: { label?: string; graph?: Automation2Graph } + body: { + label?: string; + graph?: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + active?: boolean; + } ): Promise { return await request({ url: `/api/automation2/${instanceId}/workflows/${workflowId}`, @@ -243,6 +279,25 @@ export async function fetchWorkflowRuns( return data?.runs ?? []; } +export interface CompletedRun extends Automation2Run { + workflowLabel?: string; + _modifiedAt?: number; + _createdAt?: number; +} + +export async function fetchCompletedRuns( + request: ApiRequestFunction, + instanceId: string, + limit = 20 +): Promise { + const data = await request({ + url: `/api/automation2/${instanceId}/runs/completed`, + method: 'get', + params: { limit }, + }); + return data?.runs ?? []; +} + // ------------------------------------------------------------------------- // Tasks // ------------------------------------------------------------------------- @@ -354,3 +409,124 @@ export async function fetchBrowse( }); return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service }; } + +/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */ +export async function fetchClickupTask( + request: ApiRequestFunction, + connectionId: string, + taskId: string +): Promise> { + const data = await request({ + url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`, + method: 'get', + }); + return data && typeof data === 'object' ? (data as Record) : {}; +} + +/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */ +export async function fetchClickupList( + request: ApiRequestFunction, + connectionId: string, + listId: string +): Promise> { + const data = await request({ + url: `/api/clickup/${connectionId}/lists/${listId}`, + method: 'get', + }); + return data && typeof data === 'object' ? (data as Record) : {}; +} + +/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */ +export async function fetchClickupTeam( + request: ApiRequestFunction, + connectionId: string, + teamId: string +): Promise> { + const data = await request({ + url: `/api/clickup/${connectionId}/teams/${teamId}`, + method: 'get', + }); + return data && typeof data === 'object' ? (data as Record) : {}; +} + +/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */ +export async function fetchClickupListFields( + request: ApiRequestFunction, + connectionId: string, + listId: string +): Promise<{ fields?: unknown[] } & Record> { + const data = await request({ + url: `/api/clickup/${connectionId}/lists/${listId}/fields`, + method: 'get', + }); + return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record; +} + +/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */ +export interface ClickupListTaskItem { + id?: string; + name?: string; +} + +export async function fetchClickupListTasks( + request: ApiRequestFunction, + connectionId: string, + listId: string, + options?: { page?: number; includeClosed?: boolean } +): Promise< + { tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record +> { + const data = await request({ + url: `/api/clickup/${connectionId}/lists/${listId}/tasks`, + method: 'get', + params: { + page: options?.page ?? 0, + include_closed: options?.includeClosed ?? false, + }, + }); + return (data && typeof data === 'object' ? data : {}) as { + tasks?: ClickupListTaskItem[]; + last_page?: boolean; + } & Record; +} + +/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */ +export async function loadClickupListTasksForDropdown( + request: ApiRequestFunction, + connectionId: string, + listId: string +): Promise> { + const acc: Array<{ id: string; name: string }> = []; + const seen = new Set(); + const maxPages = 12; + const pageSizeHint = 100; + for (let page = 0; page < maxPages; page++) { + const data = await fetchClickupListTasks(request, connectionId, listId, { + page, + includeClosed: false, + }); + if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) { + const err = (data as { error?: unknown }).error; + const body = (data as { body?: string }).body; + throw new Error( + typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error' + ); + } + const tasks = Array.isArray(data.tasks) ? data.tasks : []; + for (const t of tasks) { + const id = t?.id != null ? String(t.id) : ''; + if (!id || seen.has(id)) continue; + seen.add(id); + acc.push({ id, name: String(t.name ?? id) }); + } + const rawLast = (data as Record).last_page; + const last = + rawLast === true || + rawLast === 'true' || + tasks.length === 0 || + tasks.length < pageSizeHint; + if (last) break; + } + acc.sort((a, b) => a.name.localeCompare(b.name, 'de')); + return acc; +} diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index cb1b83e..7263e95 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -7,7 +7,7 @@ import { ApiRequestOptions } from '../hooks/useApi'; export interface Connection { id: string; userId: string; - authority: 'local' | 'google' | 'msft'; + authority: 'local' | 'google' | 'msft' | 'clickup'; externalId: string; externalUsername: string; externalEmail?: string; @@ -52,8 +52,8 @@ export interface PaginatedResponse { export interface CreateConnectionData { id?: string; userId?: string; - authority?: 'msft' | 'google'; - type?: 'msft' | 'google'; // Backend expects this field + authority?: 'msft' | 'google' | 'clickup'; + type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority externalId?: string; externalUsername?: string; externalEmail?: string; diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css b/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css deleted file mode 100644 index 828453e..0000000 --- a/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css +++ /dev/null @@ -1,499 +0,0 @@ -/** - * Automation2 Flow Editor Styles - * Sidebar with node list + canvas area. - */ - -.container { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; -} - -/* ============================================================================= - SIDEBAR - Node List - ============================================================================= */ - -.sidebar { - flex-shrink: 0; - width: 280px; - display: flex; - flex-direction: column; - background: var(--bg-secondary, #f8f9fa); - border-right: 1px solid var(--border-color, #e0e0e0); - overflow: hidden; -} - -.sidebarHeader { - padding: 1rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - background: var(--bg-primary, #fff); -} - -.sidebarTitle { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--text-primary, #1a1a1a); -} - -.sidebarSearch { - margin-top: 0.75rem; - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 6px; - font-size: 0.875rem; - background: var(--bg-primary, #fff); - color: var(--text-primary, #333); -} - -.sidebarSearch::placeholder { - color: var(--text-tertiary, #999); -} - -.sidebarSearch:focus { - outline: none; - border-color: var(--primary-color, #007bff); - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15); -} - -.nodeList { - flex: 1; - overflow-y: auto; - padding: 0.5rem; -} - -/* Category Groups */ -.categoryGroup { - margin-bottom: 1rem; -} - - -.categoryHeader { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary, #666); -} - -.categoryIcon { - margin-right: 0.5rem; - font-size: 0.875rem; -} - -.categoryLabel { - flex: 1; -} - -.categoryCount { - background: var(--bg-tertiary, #e9ecef); - color: var(--text-secondary, #666); - padding: 0.125rem 0.5rem; - border-radius: 10px; - font-size: 0.7rem; -} - -/* Node Items */ -.nodeItem { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; - margin-bottom: 0.25rem; - border-radius: 6px; - cursor: grab; - transition: background 0.15s; - border: 1px solid transparent; -} - -.nodeItem:hover { - background: var(--bg-hover, #e9ecef); -} - -.nodeItem:active { - cursor: grabbing; -} - -.nodeItemIcon { - flex-shrink: 0; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - margin-right: 0.75rem; - font-size: 0.875rem; -} - -.nodeItemInfo { - flex: 1; - min-width: 0; -} - -.nodeItemLabel { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary, #333); -} - -.nodeItemDesc { - display: block; - font-size: 0.75rem; - color: var(--text-secondary, #666); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Loading / Error */ -.loading, -.error { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.error { - color: var(--danger-color, #dc3545); -} - -.retryButton { - margin-top: 0.75rem; - padding: 0.5rem 1rem; - background: var(--primary-color, #007bff); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.875rem; -} - -.retryButton:hover { - background: var(--primary-hover, #0056b3); -} - -.spinner { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* ============================================================================= - CANVAS - ============================================================================= */ - -.canvas { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - background: var(--canvas-bg, #fafafa); -} - -.canvasHeader { - flex-shrink: 0; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - background: var(--bg-primary, #fff); -} - -.canvasTitle { - margin: 0; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary, #666); -} - -.canvasArea { - flex: 1; - padding: 2rem; - min-height: 400px; - overflow: hidden; -} - -.canvasDropZone { - position: relative; - min-height: 100%; - height: 100%; - overflow: hidden; - border-radius: 8px; - /* Infinite grid: on viewport, moves with pan/zoom via inline style */ - background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px); - background-repeat: repeat; -} - -.canvasContent { - position: absolute; - left: 0; - top: 0; - will-change: transform; - background: transparent; -} - -.canvasGrab { - cursor: grab; -} - -.canvasPanning { - cursor: grabbing; - user-select: none; -} - -.canvasPlaceholder { - position: absolute; - left: 2rem; - top: 2rem; - text-align: center; - color: var(--text-tertiary, #999); - border: 2px dashed var(--border-color, #dee2e6); - border-radius: 8px; - padding: 2rem 3rem; -} - -.canvasPlaceholder p { - margin: 0.25rem 0; - font-size: 0.875rem; -} - -/* Canvas Nodes */ -.canvasNode { - position: absolute; - border-radius: 8px; - border: 2px solid; - cursor: grab; - overflow: visible; -} - -.canvasNode:active { - cursor: grabbing; -} - -.canvasNodeSelected { - box-shadow: 0 0 0 2px var(--primary-color, #007bff); -} - -.canvasNodeContent { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.4rem 0.6rem; - height: 100%; - box-sizing: border-box; -} - -.canvasNodeIcon { - flex-shrink: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - font-size: 0.9rem; -} - -.canvasNodeText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.canvasNodeTitle { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary, #333); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: text; -} - -.canvasNodeTitle:hover { - text-decoration: underline; -} - -.canvasNodeComment { - font-size: 0.7rem; - color: var(--text-tertiary, #999); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - cursor: text; - min-height: 1em; -} - -.canvasNodeComment:hover { - text-decoration: underline; -} - -.canvasNodeInput { - width: 100%; - padding: 0.15rem 0.25rem; - font-size: 0.875rem; - border: 1px solid var(--primary-color, #007bff); - border-radius: 4px; - outline: none; -} - -/* Connection Handles */ -.handle { - position: absolute; - border-radius: 50%; - background: var(--bg-primary, #fff); - border: 2px solid var(--border-color, #666); - cursor: crosshair; - z-index: 2; -} - -.handle:hover, -.handleConnectable { - border-color: var(--primary-color, #007bff); - background: var(--primary-color, #007bff); -} - -.handleInput { - cursor: copy; -} - -/* Node Config Panel */ -.nodeConfigPanel { - padding: 1rem; - background: var(--bg-primary, #fff); - border-left: 1px solid var(--border-color, #e0e0e0); - width: 280px; - flex-shrink: 0; - overflow-y: auto; - min-width: 0; -} - -.nodeConfigPanel h4 { - margin: 0 0 0.75rem 0; - font-size: 0.9rem; -} - -.nodeConfigPanel label { - display: block; - font-size: 0.75rem; - color: var(--text-secondary, #666); - margin-top: 0.5rem; - margin-bottom: 0.25rem; -} - -.nodeConfigPanel input[type='text'], -.nodeConfigPanel input[type='number'], -.nodeConfigPanel select, -.nodeConfigPanel textarea { - width: 100%; - padding: 0.4rem; - font-size: 0.875rem; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 4px; -} - -.nodeConfigPanel textarea { - min-height: 60px; -} - -.nodeConfigPanel button { - margin-top: 0.5rem; - padding: 0.4rem 0.75rem; - font-size: 0.8rem; - background: var(--primary-color, #007bff); - color: white; - border: none; - border-radius: 4px; - cursor: pointer; -} - -/* Form fields editor (input.form) */ -.formFieldsList { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.formFieldRow { - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 0.5rem; - background: var(--bg-secondary, #f8f9fa); - border-radius: 6px; - border: 1px solid var(--border-color, #e0e0e0); -} - -.formFieldRowHeader { - display: flex; - align-items: flex-start; - gap: 0.35rem; -} - -.formFieldDragHandle { - flex-shrink: 0; - padding: 0.25rem; - cursor: grab; - color: var(--text-tertiary, #999); - align-self: stretch; - display: flex; - align-items: center; -} - -.formFieldDragHandle:active { - cursor: grabbing; -} - -.formFieldDragHandle:hover { - color: var(--text-secondary, #666); -} - -.formFieldInputs { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.formFieldRowFooter { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.formFieldRequiredLabel { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.75rem; - color: var(--text-secondary, #666); - cursor: pointer; -} - -.formFieldRemoveButton { - margin-left: auto; - padding: 0.25rem 0.4rem; - border: none; - background: transparent; - color: var(--text-tertiary, #999); - cursor: pointer; - border-radius: 4px; - display: flex; - align-items: center; -} - -.formFieldRemoveButton:hover { - color: var(--danger-color, #dc3545); - background: rgba(220, 53, 69, 0.1); -} diff --git a/src/components/Automation2FlowEditor/NodeConfigPanel.tsx b/src/components/Automation2FlowEditor/NodeConfigPanel.tsx deleted file mode 100644 index 36d7d1c..0000000 --- a/src/components/Automation2FlowEditor/NodeConfigPanel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes. - * Delegates to config components from configs/. - */ - -import React, { useState, useEffect } from 'react'; -import type { CanvasNode } from './FlowCanvas'; -import type { NodeType } from '../../api/automation2Api'; -import type { ApiRequestFunction } from '../../api/automation2Api'; -import { getLabel } from './utils'; -import { NODE_CONFIG_REGISTRY } from './configs'; -import styles from './Automation2FlowEditor.module.css'; - -interface NodeConfigPanelProps { - node: CanvasNode | null; - nodeType: NodeType | undefined; - language: string; - onParametersChange: (nodeId: string, parameters: Record) => void; - instanceId?: string; - request?: ApiRequestFunction; -} - -const CONFIGURABLE_PREFIXES = ['input.', 'ai.', 'email.', 'sharepoint.']; - -export const NodeConfigPanel: React.FC = ({ - node, - nodeType, - language, - onParametersChange, - instanceId, - request, -}) => { - const [params, setParams] = useState>({}); - - useEffect(() => { - setParams(node?.parameters ?? {}); - }, [node?.id, node?.parameters]); - - const updateParam = (key: string, value: unknown) => { - const next = { ...params, [key]: value }; - setParams(next); - if (node) onParametersChange(node.id, next); - }; - - const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p)); - if (!node || !isConfigurable) return null; - - const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type]; - if (!ConfigRenderer) { - return ( -
-

{getLabel(nodeType?.label, language) || node.type}

-

No configuration for {node.type}

-
- ); - } - - return ( -
-

{getLabel(nodeType?.label, language) || node.type}

- -
- ); -}; diff --git a/src/components/Automation2FlowEditor/configs/AiNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/AiNodeConfig.tsx deleted file mode 100644 index cc7b76c..0000000 --- a/src/components/Automation2FlowEditor/configs/AiNodeConfig.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * AI node config - prompt, query, document options per node type. - */ - -import React from 'react'; -import type { NodeConfigRendererProps } from './types'; - -const AI_FIELD_CONFIG: Record = { - 'ai.prompt': [ - { label: 'Prompt', key: 'prompt', type: 'textarea' }, - { label: 'Output format', key: 'resultType', type: 'select', options: ['txt', 'json', 'md', 'html', 'csv'] }, - ], - 'ai.webResearch': [{ label: 'Query', key: 'query', type: 'textarea' }], - 'ai.summarizeDocument': [ - { label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] }, - ], - 'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }], - 'ai.convertDocument': [ - { label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] }, - ], - 'ai.generateDocument': [ - { label: 'Prompt', key: 'prompt', type: 'textarea' }, - { label: 'Format', key: 'format', type: 'select', options: ['docx', 'txt', 'md'] }, - ], - 'ai.generateCode': [ - { label: 'Prompt', key: 'prompt', type: 'textarea' }, - { label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] }, - ], -}; - -export const AiNodeConfig: React.FC = ({ params, updateParam, nodeType = 'ai.prompt' }) => { - const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt']; - - return ( - <> - {fields.map((f) => ( -
- - {f.type === 'textarea' ? ( -