diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index 52df54d..5fd26fa 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -146,7 +146,14 @@ font-size: 10px; color: var(--color-text-secondary, #999); flex-shrink: 0; +} + +.scopeIcons { + display: flex; + gap: 2px; + flex-shrink: 0; margin-left: auto; + align-items: center; } .rootActions { diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 7e4860a..4332748 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -30,6 +30,8 @@ export interface FileNode { mimeType?: string; fileSize?: number; folderId?: string | null; + scope?: string; + neutralize?: boolean; } export interface TreeItem { @@ -62,6 +64,8 @@ export interface FolderTreeProps { onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -146,6 +150,22 @@ function _fileIcon(mime?: string): string { /* ── Selection context threaded through the tree ──────────────────────── */ +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_LABELS: Record = { + personal: 'Persönlich', + featureInstance: 'Instanz', + mandate: 'Mandant', + global: 'Global', +}; + interface SelectionCtx { selectedItemIds: Set; selectedFileIds: string[]; @@ -156,6 +176,8 @@ interface SelectionCtx { onDeleteFile?: (fileId: string) => Promise; onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ @@ -232,6 +254,35 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { {(file.fileSize / 1024).toFixed(0)}K )} + {!renaming && file.scope != null && ( + + + + + )} {!renaming && ( {sel.onRenameFile && !multiSelected && ( @@ -517,6 +568,7 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, + onScopeChange, onNeutralizeToggle, }: FolderTreeProps) { const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [rootDropOver, setRootDropOver] = useState(false); @@ -634,8 +686,10 @@ export default function FolderTree({ onDeleteFile, onDeleteFiles, onDeleteFolders, + onScopeChange, + onNeutralizeToggle, }; - }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); + }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]); const _handleRootDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); diff --git a/src/components/Navigation/MandateNavigation.module.css b/src/components/Navigation/MandateNavigation.module.css index dcb8358..f3cf712 100644 --- a/src/components/Navigation/MandateNavigation.module.css +++ b/src/components/Navigation/MandateNavigation.module.css @@ -282,6 +282,27 @@ margin-top: 0.5rem; } +/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */ +.renameButton { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--text-tertiary, #888); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; +} + +.renameButton:hover { + color: var(--primary-color, #2563eb); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); +} + /* Dark Theme */ :global(.dark-theme) .separator { background: var(--border-dark, #333); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index e8cc3bc..9f9c1dc 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -20,7 +20,7 @@ * - Users, Mandates, Roles, ... */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useNavigation } from '../../hooks/useNavigation'; import type { DynamicBlock, @@ -31,8 +31,9 @@ import type { FeatureView } from '../../hooks/useNavigation'; import { getPageIcon } from '../../config/pageRegistry'; -import { FaSpinner } from 'react-icons/fa'; +import { FaSpinner, FaPen } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; +import api from '../../api'; import styles from './MandateNavigation.module.css'; // ============================================================================= @@ -84,16 +85,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { * Convert a FeatureInstance to TreeNodeItem (with feature icon) * Instance node gets path to first view so clicking the instance name navigates to dashboard. * Shows the feature icon next to the instance name for visual distinction. + * If user is instance admin, a rename icon appears on hover. */ -function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { +function featureInstanceToTreeNode( + instance: FeatureInstance, + featureUiComponent: string, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem { const children = instance.views.map(featureViewToTreeNode); + const renameAction = instance.isAdmin && onRename ? ( + + ) : undefined; + return { id: instance.id, label: instance.uiLabel, - icon: getPageIcon(featureUiComponent), // Use feature icon for instance + icon: getPageIcon(featureUiComponent), path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, children, defaultExpanded: false, + actions: renameAction, }; } @@ -106,16 +123,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent * Before: Mandate → Feature → Instance → Views * Now: Mandate → Instance (with feature icon) → Views */ -function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { +function navigationMandateToTreeNode( + mandate: NavigationMandate, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } - // Flatten: collect all instances from all features directly under mandate const instanceNodes: TreeNodeItem[] = []; for (const feature of mandate.features) { for (const instance of feature.instances) { - instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); + instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename)); } } @@ -134,9 +153,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | /** * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) */ -function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] { +function dynamicBlockToTreeNodes( + block: DynamicBlock, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem[] { return block.mandates - .map(navigationMandateToTreeNode) + .map((m) => navigationMandateToTreeNode(m, onRename)) .filter((node): node is TreeNodeItem => node !== null); } @@ -169,18 +191,19 @@ const EmptyState: React.FC = () => ( // ============================================================================= export const MandateNavigation: React.FC = () => { - // Fetch navigation from new API (blocks structure, already filtered by permissions) - const { blocks, loading } = useNavigation('de'); - - // Build navigation items from blocks - // Groups static items into collapsible containers: - // - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) - // - "Administration": admin items, possibly with subgroups - // - Dynamic block (mandates) renders between them + const { blocks, loading, refresh } = useNavigation('de'); + + const _handleRename = useCallback((instanceId: string, currentLabel: string) => { + const newLabel = window.prompt('Neuer Name:', currentLabel); + if (!newLabel || newLabel.trim() === currentLabel) return; + api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }) + .then(() => refresh()) + .catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message))); + }, [refresh]); + const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; - // Collect static items by category const meineSichtItems: NavigationItem[] = []; let adminItems: NavigationItem[] = []; let adminSubgroups: NavSubgroup[] = []; @@ -199,15 +222,13 @@ export const MandateNavigation: React.FC = () => { } } - // "Meine Sicht" - collapsible container for user-facing pages if (meineSichtItems.length > 0) { items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); } - // Dynamic block: mandates with feature instances for (const block of blocks) { if (block.type === 'dynamic') { - const mandateNodes = dynamicBlockToTreeNodes(block); + const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename); if (mandateNodes.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); @@ -215,7 +236,6 @@ export const MandateNavigation: React.FC = () => { } } - // "Administration" - collapsible container for admin pages (with subgroup support) if (adminSubgroups.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ @@ -236,7 +256,7 @@ export const MandateNavigation: React.FC = () => { } return items; - }, [blocks]); + }, [blocks, _handleRename]); // Check if user has any navigation (static or dynamic) const hasNavigation = blocks.length > 0; diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 5e9f57c..c7c0c7c 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -257,6 +257,22 @@ color: white; } +/* ============================================ */ +/* NODE ACTIONS (hover-reveal inline icons) */ +/* ============================================ */ + +.nodeActions { + display: none; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + margin-left: auto; +} + +.treeNode:hover .nodeActions { + display: flex; +} + /* ============================================ */ /* DARK THEME */ /* ============================================ */ diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index babceee..e60cfac 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -47,6 +47,8 @@ export interface TreeNodeItem { level?: number; /** Data attribute for testing/identification */ dataId?: string; + /** Inline action element rendered at the end of the row (e.g. rename icon) */ + actions?: ReactNode; } export interface TreeSectionItem { @@ -219,6 +221,11 @@ const TreeNode: React.FC = ({ {node.badge} )} + {node.actions && ( + e.stopPropagation()}> + {node.actions} + + )} ); diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 656976d..44697ae 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '../../hooks/useUsers'; import { NotificationBell } from '../NotificationBell'; +import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant'; import styles from './UserSection.module.css'; export const UserSection: React.FC = () => { @@ -16,6 +17,7 @@ export const UserSection: React.FC = () => { const [isLoggingOut, setIsLoggingOut] = useState(false); const [showMenu, setShowMenu] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false); + const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden()); const handleLogout = async () => { setIsLoggingOut(true); @@ -41,6 +43,13 @@ export const UserSection: React.FC = () => { setShowLegalModal(true); setShowMenu(false); }; + + const handleOnboarding = () => { + _showOnboarding(); + setOnboardingHidden(false); + navigate('/', { state: { showOnboarding: Date.now() } }); + setShowMenu(false); + }; if (!user) { return null; @@ -61,7 +70,7 @@ export const UserSection: React.FC = () => { + {onboardingHidden && ( + + )} +
@@ -170,34 +214,78 @@ const OnboardingAssistant: React.FC = ({
- {steps.map((step) => ( -
- - {step.completed ? '\u2713' : '\u25CB'} - -
-
- {step.label} -
-
- {step.description} + {steps.map((step, idx) => { + const isNextStep = !step.completed && steps.slice(0, idx).every(s => s.completed); + return ( +
+
+ + {step.completed ? '\u2713' : '\u25CB'} + +
+
+ {step.label} +
+
+ {step.description} +
+
+ {step.action && !step.completed && ( + {'\u2192'} + )}
+ {isNextStep && _CALLOUTS[step.id] && ( +
+ {_CALLOUTS[step.id]} +
+ )}
- {step.action && !step.completed && ( - {'\u2192'} - )} -
- ))} + ); + })} +
+ +
+ +
); diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css index 5118b5b..008c846 100644 --- a/src/components/UnifiedDataBar/ChatsTab.module.css +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -20,6 +20,23 @@ color: var(--text-primary, #111); } +.createBtn { + padding: 6px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: var(--accent, #4f46e5); + color: #fff; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + line-height: 1; + transition: background 0.15s; +} + +.createBtn:hover { + background: var(--accent-hover, #4338ca); +} + .modeToggle { padding: 6px 8px; border: 1px solid var(--border-color, #d1d5db); @@ -33,12 +50,50 @@ background: var(--bg-active, #eef2ff); } -.loading { +/* ── Aktiv / Archiv filter tabs ── */ + +.filterTabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--border-color, #e5e7eb); +} + +.filterTab { + flex: 1; + padding: 6px 0; + font-size: 0.8rem; + font-weight: 600; + text-align: center; + border: none; + background: none; + cursor: pointer; + color: var(--text-secondary, #6b7280); + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.15s, border-color 0.15s; +} + +.filterTab:hover { + color: var(--text-primary, #111); +} + +.filterTabActive { + color: var(--accent, #4f46e5); + border-bottom-color: var(--accent, #4f46e5); +} + +/* ── Loading / Empty ── */ + +.loading, +.emptyState { padding: 16px; text-align: center; color: var(--text-secondary, #6b7280); + font-size: 0.85rem; } +/* ── Chat list ── */ + .flatList, .tree { display: flex; @@ -46,33 +101,100 @@ } .chatItem { - padding: 8px 10px; + padding: 6px 10px; border-radius: 6px; cursor: pointer; display: flex; - justify-content: space-between; align-items: center; font-size: 0.85rem; + position: relative; + gap: 6px; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; } .chatItem:hover { background: var(--bg-hover, rgba(0, 0, 0, 0.04)); } +.chatItemActive { + background: var(--primary-light, #eef2ff); + border-color: var(--accent, #4f46e5); + font-weight: 500; +} + +.chatItemActive:hover { + background: var(--primary-light, #eef2ff); +} + +.chatItemArchived { + opacity: 0.65; +} + +.chatDate { + font-size: 0.7rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + min-width: 36px; +} + .chatLabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; + min-width: 0; } -.chatDate { - font-size: 0.75rem; - color: var(--text-secondary, #9ca3af); +/* ── Inline action icons (show on hover) ── */ + +.chatActions { + display: none; + gap: 2px; flex-shrink: 0; - margin-left: 8px; + margin-left: auto; + align-items: center; } +.chatItem:hover .chatActions { + display: flex; +} + +.actionBtn { + background: none; + border: none; + cursor: pointer; + padding: 2px 3px; + border-radius: 4px; + font-size: 0.75rem; + line-height: 1; + transition: background 0.15s; + opacity: 0.7; +} + +.actionBtn:hover { + background: rgba(0, 0, 0, 0.06); + opacity: 1; +} + +.actionBtnDanger:hover { + background: rgba(220, 38, 38, 0.1); +} + +.renameInput { + flex: 1; + min-width: 0; + font-size: 0.85rem; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--accent, #4f46e5); + outline: none; + background: var(--bg-input, #fff); + color: var(--text-primary, #111); +} + +/* ── Tree groups ── */ + .treeGroup { margin-bottom: 2px; } @@ -118,7 +240,8 @@ } @media (prefers-color-scheme: dark) { - .search { + .search, + .renameInput { background: var(--bg-input-dark, #1f2937); border-color: var(--border-dark, #374151); color: #f3f4f6; @@ -127,8 +250,28 @@ .treeGroupHeader:hover { background: rgba(255, 255, 255, 0.05); } + .chatItemActive, + .chatItemActive:hover { + background: rgba(79, 70, 229, 0.15); + border-color: var(--accent, #4f46e5); + } .treeGroupCount { background: #374151; color: #9ca3af; } + .actionBtn:hover { + background: rgba(255, 255, 255, 0.08); + } + .actionBtnDanger:hover { + background: rgba(220, 38, 38, 0.15); + } + .createBtn { + border-color: var(--border-dark, #374151); + } + .filterTabs { + border-bottom-color: var(--border-dark, #374151); + } + .filterTab:hover { + color: #f3f4f6; + } } diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index 9391756..cfb961e 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import styles from './ChatsTab.module.css'; @@ -6,9 +6,10 @@ import styles from './ChatsTab.module.css'; interface ChatItem { id: string; label: string; - updatedAt?: string; + updatedAt?: string | number; featureInstanceId?: string; featureCode?: string; + status?: string; } interface ChatGroup { @@ -18,24 +19,63 @@ interface ChatGroup { chats: ChatItem[]; } +type ChatFilter = 'active' | 'archived'; + interface ChatsTabProps { context: UdbContext; onSelectChat?: (chatId: string, featureInstanceId: string) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void; + activeWorkflowId?: string; + onCreateNew?: () => void; + onRenameChat?: (chatId: string, newName: string) => void | Promise; + onDeleteChat?: (chatId: string) => void | Promise; } -const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }) => { +function _formatRelativeTime(dateStr?: string | number): string { + if (!dateStr) return ''; + const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr); + if (isNaN(d.getTime())) return ''; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffH = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMin < 1) return 'gerade eben'; + if (diffMin < 60) return `${diffMin}m`; + if (diffH < 24) return `${diffH}h`; + if (diffDays === 1) return 'gestern'; + if (diffDays < 7) return `vor ${diffDays}d`; + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +const ChatsTab: React.FC = ({ + context, + onSelectChat, + onDragStart, + activeWorkflowId, + onCreateNew, + onRenameChat, + onDeleteChat, +}) => { const [groups, setGroups] = useState([]); const [flatMode, setFlatMode] = useState(false); const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('active'); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const renameInputRef = useRef(null); 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 response = await api.get( + `/api/workspace/${context.instanceId}/workflows`, + { params: { includeArchived: true } }, + ); + const workflows = response.data?.workflows || response.data?.data || []; const groupMap = new Map(); for (const wf of workflows) { @@ -51,15 +91,20 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart groupMap.get(fiId)!.chats.push({ id: wf.id, label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`, - updatedAt: wf.updatedAt || wf.createdAt, + updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt, featureInstanceId: fiId, featureCode: wf.featureCode, + status: wf.status || 'active', }); } const sorted = Array.from(groupMap.values()); sorted.forEach(g => - g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')), + g.chats.sort((a, b) => { + const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime(); + const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime(); + return tb - ta; + }), ); setGroups(sorted); @@ -75,6 +120,19 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart useEffect(() => { _loadChats(); }, [_loadChats]); + useEffect(() => { + if (activeWorkflowId) { + _loadChats(); + } + }, [activeWorkflowId]); + + useEffect(() => { + if (editingId && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [editingId]); + const _toggleGroup = (id: string) => { setExpandedGroups(prev => { const next = new Set(prev); @@ -83,18 +141,161 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }); }; + const _startEditing = (chat: ChatItem) => { + if (!onRenameChat) return; + setEditingId(chat.id); + setEditName(chat.label); + }; + + const _commitRename = async (chatId: string) => { + const trimmed = editName.trim(); + setEditingId(null); + if (!trimmed || !onRenameChat) return; + await onRenameChat(chatId, trimmed); + _loadChats(); + }; + + const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => { + if (e.key === 'Enter') { + e.preventDefault(); + _commitRename(chatId); + } else if (e.key === 'Escape') { + setEditingId(null); + } + }; + + const _archiveChat = useCallback(async (chatId: string) => { + try { + await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); + _loadChats(); + } catch (err) { + console.error('Failed to archive chat:', err); + } + }, [context.instanceId, _loadChats]); + + const _restoreChat = useCallback(async (chatId: string) => { + try { + await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); + _loadChats(); + } catch (err) { + console.error('Failed to restore chat:', err); + } + }, [context.instanceId, _loadChats]); + + const _isArchived = (chat: ChatItem) => chat.status === 'archived'; + + const _applyFilter = (chats: ChatItem[]) => + chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c)); + const _filteredGroups = groups - .map(g => ({ - ...g, - chats: search - ? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())) - : g.chats, - })) + .map(g => { + let chats = _applyFilter(g.chats); + if (search) { + chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())); + } + return { ...g, chats }; + }) .filter(g => g.chats.length > 0); const _allChats = _filteredGroups .flatMap(g => g.chats) - .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + .sort((a, b) => { + const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime(); + const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime(); + return tb - ta; + }); + + const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0); + const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0); + + const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => { + const isActive = activeWorkflowId === chat.id; + const isEditing = editingId === chat.id; + const archived = _isArchived(chat); + + const itemClassName = [ + styles.chatItem, + isActive ? styles.chatItemActive : '', + archived ? styles.chatItemArchived : '', + ].filter(Boolean).join(' '); + + return ( +
{ + if (!isEditing) onSelectChat?.(chat.id, featureInstanceId); + }} + draggable={!!onDragStart && !isEditing} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {isEditing ? ( + setEditName(e.target.value)} + onBlur={() => _commitRename(chat.id)} + onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> + + {_formatRelativeTime(chat.updatedAt)} + + + {chat.label} + + + {onRenameChat && ( + + )} + {archived ? ( + + ) : ( + + )} + {onDeleteChat && ( + + )} + + + )} +
+ ); + }; if (loading) return
Lade Chats...
; @@ -108,6 +309,11 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart value={search} onChange={(e) => setSearch(e.target.value)} /> + {onCreateNew && ( + + )}
+
+ + +
+ {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()} - - )} -
- ))} + {_allChats.map((chat) => + _renderChatItem(chat, chat.featureInstanceId || context.instanceId), + )}
) : (
@@ -158,27 +362,21 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart
{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} -
- ))} + {group.chats.map((chat) => + _renderChatItem(chat, group.featureInstanceId), + )}
)} ))} )} + + {_allChats.length === 0 && ( +
+ {filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'} +
+ )} ); }; diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css index a79c04c..7a48a75 100644 --- a/src/components/UnifiedDataBar/FilesTab.module.css +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; } .loading, diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 65183c9..c96ebdd 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,26 +1,22 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; +import FolderTree from '../../components/FolderTree/FolderTree'; +import type { FileNode } from '../../components/FolderTree/FolderTree'; +import { useFileContext } from '../../contexts/FileContext'; import styles from './FilesTab.module.css'; interface FileEntry { id: string; fileName: string; mimeType?: string; + fileSize?: number; + folderId?: string | null; + tags?: 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; @@ -29,6 +25,27 @@ interface FilesTabProps { const FilesTab: React.FC = ({ context, onFileSelect }) => { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const fileInputRef = useRef(null); + + const { + folders, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFolders, + handleMoveFile, + handleMoveFiles: contextMoveFiles, + handleFileDelete, + handleDownloadFolder, + expandedFolderIds, + toggleFolderExpanded, + } = useFileContext(); const _loadFiles = useCallback(async () => { setLoading(true); @@ -40,9 +57,11 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { id: f.id, fileName: f.fileName || f.name || 'unknown', mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + tags: f.tags || [], scope: f.scope || 'personal', neutralize: f.neutralize || false, - fileSize: f.fileSize, })), ); } catch (err) { @@ -56,73 +75,245 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { _loadFiles(); }, [_loadFiles]); - const _cycleScope = async (file: FileEntry) => { - const currentIdx = _SCOPE_CYCLE.indexOf(file.scope); - const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length]; + const _folderNodes = useMemo(() => + folders.map(f => ({ + id: f.id, + name: f.name, + parentId: f.parentId ?? null, + })), + [folders], + ); + + const _fileNodes: FileNode[] = useMemo(() => { + let result = files; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter(f => + f.fileName.toLowerCase().includes(q) + || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), + ); + } + return result + .sort((a, b) => a.fileName.localeCompare(b.fileName)) + .map(f => ({ + id: f.id, + fileName: f.fileName, + mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + scope: f.scope, + neutralize: f.neutralize, + })); + }, [files, searchQuery]); + + const _refreshAll = useCallback(() => { + _loadFiles(); + refreshFolders(); + }, [_loadFiles, refreshFolders]); + + const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { + if (!context.instanceId || uploading) return; + setUploading(true); try { - await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope }); - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f))); + for (const file of Array.from(fileList)) { + const formData = new FormData(); + formData.append('file', file); + formData.append('featureInstanceId', context.instanceId); + await api.post('/api/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + _refreshAll(); + } catch (err) { + console.error('File upload failed:', err); + } finally { + setUploading(false); + } + }, [context.instanceId, uploading, _refreshAll]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + } + }, []); + + const _handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const _handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (e.dataTransfer.files.length > 0) { + _uploadFiles(e.dataTransfer.files); + } + }, [_uploadFiles]); + + const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + _uploadFiles(e.target.files); + e.target.value = ''; + } + }, [_uploadFiles]); + + const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await handleMoveFile(fileId, targetFolderId); + _loadFiles(); + }, [handleMoveFile, _loadFiles]); + + const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await contextMoveFiles(fileIds, targetFolderId); + _loadFiles(); + }, [contextMoveFiles, _loadFiles]); + + const _onDeleteFolder = useCallback(async (folderId: string) => { + await handleDeleteFolder(folderId); + if (selectedFolderId === folderId) setSelectedFolderId(null); + _loadFiles(); + }, [handleDeleteFolder, selectedFolderId, _loadFiles]); + + const _onRenameFile = useCallback(async (fileId: string, newName: string) => { + await api.put(`/api/files/${fileId}`, { fileName: newName }); + _loadFiles(); + }, [_loadFiles]); + + const _onDeleteFile = useCallback(async (fileId: string) => { + await handleFileDelete(fileId); + _loadFiles(); + }, [handleFileDelete, _loadFiles]); + + const _onDeleteFiles = useCallback(async (fileIds: string[]) => { + await api.post('/api/files/batch-delete', { fileIds }); + _loadFiles(); + }, [_loadFiles]); + + const _onDeleteFolders = useCallback(async (folderIds: string[]) => { + await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); + refreshFolders(); + _loadFiles(); + }, [refreshFolders, _loadFiles]); + + const _onScopeChange = useCallback(async (fileId: string, newScope: string) => { + setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f))); + try { + await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); } catch (err) { console.error('Failed to update scope:', err); + _loadFiles(); } - }; + }, [_loadFiles]); - const _toggleNeutralize = async (file: FileEntry) => { + const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { + setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f))); 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)), - ); + await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); } catch (err) { console.error('Failed to toggle neutralize:', err); + _loadFiles(); } - }; + }, [_loadFiles]); if (loading) return
Lade Dateien...
; return ( -
- {files.length === 0 ? ( -
Keine Dateien vorhanden
- ) : ( -
- {files.map((file) => ( -
onFileSelect?.(file.id)} - > - {file.fileName} -
- - -
-
- ))} +
+ {isDragOver && ( +
+ Dateien hier ablegen
)} + +
+ Files +
+ + +
+
+ + + + setSearchQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px', + }} + /> + +
+ + + {_fileNodes.length === 0 && ( +
+ {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} +
+ )} +
+
- {'\uD83D\uDC64'} Pers\u00F6nlich + {'\uD83D\uDC64'} Persönlich {'\uD83D\uDC65'} Instanz {'\uD83C\uDFE2'} Mandant {'\uD83D\uDD12'} Neutralisiert diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 403dd63..f1a3fca 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1,22 +1,1131 @@ -import React from 'react'; +/** + * SourcesTab – Full data-source management inside the Unified Data Bar. + * + * Tree structure (Browse Sources): + * UserConnection (Level 1, loaded on mount) + * └─ Service (Level 2, loaded when connection expanded) + * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) + * + * Feature Data tree: + * MandateGroup + * └─ FeatureConnection (feature instance) + * └─ FeatureTable (tables exposed by that instance) + * + * Active Sources sections show scope-cycling and neutralize-toggle buttons. + */ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import { getPageIcon } from '../../config/pageRegistry'; import styles from './SourcesTab.module.css'; +/* ─── Types (inline, no external imports) ────────────────────────────── */ + +interface UdbDataSource { + id: string; + connectionId: string; + sourceType: string; + path: string; + label: string; + displayPath?: string; + scope: string; + neutralize: boolean; +} + +interface UdbFeatureDataSource { + id: string; + featureInstanceId: string; + featureCode: string; + tableName: string; + objectKey: string; + label: string; + scope: string; + neutralize: boolean; +} + +interface TreeNode { + key: string; + label: string; + icon: string; + type: 'connection' | 'service' | 'folder' | 'file'; + expanded: boolean; + loading: boolean; + children: TreeNode[] | null; + connectionId: string; + service?: string; + path?: string; + displayPath?: string; + authority?: string; +} + +interface FeatureConnectionNode { + featureInstanceId: string; + featureCode: string; + mandateId?: string; + label: string; + icon: string; + tableCount: number; + expanded: boolean; + loading: boolean; + tables: FeatureTableNode[] | null; +} + +interface MandateGroupNode { + mandateId: string; + mandateLabel: string; + expanded: boolean; + featureConnections: FeatureConnectionNode[]; +} + +interface FeatureTableNode { + objectKey: string; + tableName: string; + label: Record; + fields: string[]; +} + +/* ─── Props ──────────────────────────────────────────────────────────── */ + interface SourcesTabProps { context: UdbContext; - renderDataSourcePanel?: (instanceId: string) => React.ReactNode; } -const SourcesTab: React.FC = ({ context, renderDataSourcePanel }) => { - if (renderDataSourcePanel) { - return
{renderDataSourcePanel(context.instanceId)}
; +/* ─── Icons ──────────────────────────────────────────────────────────── */ + +const _AUTHORITY_ICONS: Record = { + msft: '\uD83D\uDFE6', + google: '\uD83D\uDFE9', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', +}; + +const _SERVICE_ICONS: Record = { + sharepoint: '\uD83D\uDCC1', + onedrive: '\u2601\uFE0F', + outlook: '\uD83D\uDCE7', + teams: '\uD83D\uDCAC', + drive: '\uD83D\uDCC2', + gmail: '\uD83D\uDCE8', + files: '\uD83D\uDCC2', +}; + +/* ─── Source colors & icons ──────────────────────────────────────────── */ + +const _SOURCE_COLORS: Record = { + sharepointFolder: '#0078d4', + onedriveFolder: '#0078d4', + outlookFolder: '#0078d4', + googleDriveFolder: '#34a853', + gmailFolder: '#ea4335', + ftpFolder: '#795548', +}; + +function _getSourceColor(sourceType: string): string { + return _SOURCE_COLORS[sourceType] || '#1976d2'; +} + +function _getSourceIcon(sourceType: string): string { + const map: Record = { + sharepointFolder: '\uD83D\uDCC1', + onedriveFolder: '\u2601\uFE0F', + outlookFolder: '\uD83D\uDCE7', + googleDriveFolder: '\uD83D\uDCC2', + gmailFolder: '\uD83D\uDCE8', + ftpFolder: '\uD83D\uDD17', + }; + return map[sourceType] || '\uD83D\uDCC1'; +} + +/* ─── Scope / Neutralize constants ───────────────────────────────────── */ + +const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_LABELS: Record = { + personal: 'Personal', + featureInstance: 'Feature Instance', + mandate: 'Mandate', + global: 'Global', +}; + +function _nextScope(current: string): string { + const idx = _SCOPE_ORDER.indexOf(current); + if (idx === -1) return _SCOPE_ORDER[0]; + return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length]; +} + +/* ─── Tree helpers ───────────────────────────────────────────────────── */ + +function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { + return nodes.map(n => { + if (n.key === key) return updater(n); + if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; + return n; + }); +} + +function _mapFeatureTreeUpdate( + prev: MandateGroupNode[], + featureInstanceId: string, + updater: (n: FeatureConnectionNode) => FeatureConnectionNode, +): MandateGroupNode[] { + return prev.map(g => ({ + ...g, + featureConnections: g.featureConnections.map(n => + n.featureInstanceId === featureInstanceId ? updater(n) : n + ), + })); +} + +function _findFeatureInstanceMeta( + groups: MandateGroupNode[], + featureInstanceId: string, +): { mandateLabel: string; instanceLabel: string } | null { + for (const g of groups) { + const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); + if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; } + return null; +} + +function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string { + const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; + return pathPart ? `${connLabel} / ${pathPart}` : connLabel; +} + +function _featureDataSourceHoverTitle( + meta: { mandateLabel: string; instanceLabel: string } | null, + fds: UdbFeatureDataSource, +): string { + const parts: string[] = []; + if (meta) { + parts.push(meta.mandateLabel, meta.instanceLabel); + } + const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName + ? `${fds.label} (${fds.tableName})` + : (fds.label || fds.tableName); + parts.push(labelPart); + if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { + parts.push(fds.objectKey); + } + return parts.join(' / '); +} + +/* ─── Data fetching (module-level) ───────────────────────────────────── */ + +async function _loadServices(instanceId: string, connectionId: string): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); + const services = res.data.services || []; + return services.map((s: any) => ({ + key: `svc-${connectionId}-${s.service}`, + label: s.label || s.service, + icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', + type: 'service' as const, + expanded: false, + loading: false, + children: null, + connectionId, + service: s.service, + path: '/', + displayPath: s.label || s.service, + })); +} + +async function _browseService( + instanceId: string, + connectionId: string, + service: string, + path: string, + parentDisplayPath: string | undefined, +): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { + params: { service, path }, + }); + const items = res.data.items || []; + return items.map((entry: any, idx: number) => { + const seg = entry.name || ''; + const displayPath = parentDisplayPath + ? `${parentDisplayPath} / ${seg}` + : seg; + return { + key: `item-${connectionId}-${service}-${entry.path || idx}`, + label: entry.name, + icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), + type: entry.isFolder ? 'folder' as const : 'file' as const, + expanded: false, + loading: false, + children: entry.isFolder ? null : [], + connectionId, + service, + path: entry.path, + displayPath, + }; + }); +} + +function _fileIcon(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', + xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', + ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', + txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', + png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', + zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', + mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', + mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', + }; + return map[ext] || '\uD83D\uDCC4'; +} + +/* ─── Spinner (inline) ───────────────────────────────────────────────── */ + +function _Spinner(): React.ReactElement { + return ( + + ); +} + +/* ─── Component ──────────────────────────────────────────────────────── */ + +const SourcesTab: React.FC = ({ context }) => { + const instanceId = context.instanceId; + + /* ── Active sources (fetched internally) ── */ + const [dataSources, setDataSources] = useState([]); + const [featureDataSources, setFeatureDataSources] = useState([]); + + /* ── Browse tree state ── */ + const [tree, setTree] = useState([]); + const [loadingRoot, setLoadingRoot] = useState(false); + const [addingPath, setAddingPath] = useState(null); + + /* ── Feature tree state ── */ + const [featureTree, setFeatureTree] = useState([]); + const [loadingFeatures, setLoadingFeatures] = useState(false); + const [addingFeatureKey, setAddingFeatureKey] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); + + /* ── Fetch active personal data sources ── */ + const _fetchDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({ + id: d.id, + connectionId: d.connectionId, + sourceType: d.sourceType, + path: d.path, + label: d.label, + displayPath: d.displayPath, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setDataSources(list); + }) + .catch(() => { if (mountedRef.current) setDataSources([]); }); + }, [instanceId]); + + /* ── Fetch active feature data sources ── */ + const _fetchFeatureDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/feature-datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({ + id: d.id, + featureInstanceId: d.featureInstanceId, + featureCode: d.featureCode, + tableName: d.tableName, + objectKey: d.objectKey, + label: d.label, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setFeatureDataSources(list); + }) + .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); + }, [instanceId]); + + useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); + useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); + + /* ── Load Level 1: UserConnections ── */ + const _loadConnections = useCallback(() => { + if (!instanceId) return; + setLoadingRoot(true); + api.get(`/api/workspace/${instanceId}/connections`) + .then(res => { + if (!mountedRef.current) return; + const conns = res.data.connections || []; + const nodes: TreeNode[] = conns + .filter((c: any) => c.status === 'active') + .map((c: any) => ({ + key: `conn-${c.id}`, + label: c.externalEmail || c.externalUsername || c.authority, + icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', + type: 'connection' as const, + expanded: false, + loading: false, + children: null, + connectionId: c.id, + authority: c.authority, + })); + setTree(nodes); + }) + .catch(() => { if (mountedRef.current) setTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); + }, [instanceId]); + + useEffect(() => { _loadConnections(); }, [_loadConnections]); + + /* ── Generic tree update helper ── */ + const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { + setTree(prev => _mapTree(prev, key, updater)); + }, []); + + /* ── Toggle expand/collapse ── */ + const _toggleNode = useCallback(async (node: TreeNode) => { + if (node.expanded) { + _updateNode(node.key, n => ({ ...n, expanded: false })); + return; + } + + if (node.children !== null) { + _updateNode(node.key, n => ({ ...n, expanded: true })); + return; + } + + _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); + + try { + let children: TreeNode[] = []; + + if (node.type === 'connection') { + children = await _loadServices(instanceId, node.connectionId); + } else if (node.type === 'service' || node.type === 'folder') { + children = await _browseService( + instanceId, + node.connectionId, + node.service!, + node.path || '/', + node.displayPath || node.label, + ); + } + + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children })); + } + } catch { + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); + } + } + }, [instanceId, _updateNode]); + + /* ── Add as DataSource ── */ + const _addAsDataSource = useCallback(async (node: TreeNode) => { + if (!node.service || !node.connectionId) return; + setAddingPath(node.key); + try { + const sourceTypeMap: Record = { + sharepoint: 'sharepointFolder', + onedrive: 'onedriveFolder', + outlook: 'outlookFolder', + drive: 'googleDriveFolder', + gmail: 'gmailFolder', + files: 'ftpFolder', + }; + await api.post(`/api/workspace/${instanceId}/datasources`, { + connectionId: node.connectionId, + sourceType: sourceTypeMap[node.service] || node.service, + path: node.path || '/', + label: node.label, + displayPath: node.displayPath || node.label, + }); + _fetchDataSources(); + } catch (err) { + console.error('Failed to add data source:', err); + } finally { + if (mountedRef.current) setAddingPath(null); + } + }, [instanceId, _fetchDataSources]); + + /* ── Remove DataSource ── */ + const _removeDatasource = useCallback(async (dsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); + _fetchDataSources(); + } catch (err) { + console.error('Failed to remove data source:', err); + } + }, [instanceId, _fetchDataSources]); + + /* ── Check if a path is already added ── */ + const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { + return dataSources.some(ds => + ds.connectionId === connectionId && ds.path === (path || '/'), + ); + }, [dataSources]); + + /* ── Scope change (personal data source, optimistic) ── */ + const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => { + const newScope = _nextScope(ds.scope); + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (personal data source, optimistic) ── */ + const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => { + const newValue = !ds.neutralize; + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d)); + } + }, []); + + /* ── Scope change (feature data source, optimistic) ── */ + const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { + const newScope = _nextScope(fds.scope); + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (feature data source, optimistic) ── */ + const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => { + const newValue = !fds.neutralize; + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d)); + } + }, []); + + /* ── Feature Connections: Load Level 1 ── */ + const _loadFeatureConnections = useCallback(() => { + if (!instanceId) return; + setLoadingFeatures(true); + api.get(`/api/workspace/${instanceId}/feature-connections`) + .then(res => { + if (!mountedRef.current) return; + const groups = res.data.featureConnectionsByMandate || []; + setFeatureTree(groups.map((g: any) => ({ + mandateId: g.mandateId, + mandateLabel: g.mandateLabel || g.mandateId, + expanded: true, + featureConnections: (g.featureConnections || []).map((c: any) => ({ + featureInstanceId: c.featureInstanceId, + featureCode: c.featureCode, + mandateId: c.mandateId, + label: c.label, + icon: c.icon || '\uD83D\uDDC3\uFE0F', + tableCount: c.tableCount || 0, + expanded: false, + loading: false, + tables: null, + })), + }))); + }) + .catch(() => { if (mountedRef.current) setFeatureTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); + }, [instanceId]); + + useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); + + /* ── Feature Connections: Toggle mandate group ── */ + const _toggleMandateGroup = useCallback((mandateId: string) => { + setFeatureTree(prev => prev.map(g => + g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g + )); + }, []); + + /* ── Feature Connections: Toggle expand ── */ + const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { + if (node.expanded) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); + return; + } + + if (node.tables !== null) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); + return; + } + + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: true, expanded: true, + }))); + + try { + const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); + const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ + objectKey: t.objectKey, + tableName: t.tableName, + label: t.label || {}, + fields: t.fields || [], + })); + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables, + }))); + } + } catch { + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables: [], + }))); + } + } + }, [instanceId]); + + /* ── Feature: Add table as FeatureDataSource ── */ + const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { + const key = `${node.featureInstanceId}-${table.tableName}`; + setAddingFeatureKey(key); + try { + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: table.tableName, + objectKey: table.objectKey, + label: table.label?.en || table.label?.de || table.tableName, + }); + _fetchFeatureDataSources(); + } catch (err) { + console.error('Failed to add feature data source:', err); + } finally { + if (mountedRef.current) setAddingFeatureKey(null); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: Remove FeatureDataSource ── */ + const _removeFeatureDataSource = useCallback(async (fdsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); + _fetchFeatureDataSources(); + } catch (err) { + console.error('Failed to remove feature data source:', err); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: check if table already added ── */ + const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { + return featureDataSources.some(fds => + fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, + ); + }, [featureDataSources]); + + /* ── Render ── */ return ( -
-
- Datenquellen werden \u00FCber den Workspace verwaltet. +
+ {/* ── Active Personal Sources ── */} + {dataSources.length > 0 && ( +
+
+ Active Personal Sources +
+ {dataSources.map(ds => { + const connColor = _getSourceColor(ds.sourceType); + const connNode = tree.find(n => n.connectionId === ds.connectionId); + const connLabel = connNode?.label || ds.connectionId; + const folder = ds.label || ds.path || ds.id; + return ( +
+ {_getSourceIcon(ds.sourceType)} + + {connLabel} – {folder} + + + + +
+ ); + })} +
+
+ )} + + {/* ── Browse Sources header ── */} +
+ + Browse Sources + +
+ + {/* ── Browse Sources tree ── */} + {loadingRoot && tree.length === 0 && ( +
+ Loading connections... +
+ )} + + {!loadingRoot && tree.length === 0 && ( +
+ No active connections found. +
+ )} + + {tree.map(node => ( + <_TreeNodeView + key={node.key} + node={node} + depth={0} + onToggle={_toggleNode} + onAdd={_addAsDataSource} + isAdded={_isAdded} + addingPath={addingPath} + /> + ))} + + {/* ── Divider ── */} +
+ + {/* ── Active Feature Sources ── */} + {featureDataSources.length > 0 && ( +
+
+ Active Feature Sources +
+ {featureDataSources.map(fds => { + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + const fdsConnLabel = meta?.instanceLabel || fds.tableName; + return ( +
+ + {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {fdsConnLabel} – {fds.tableName} + + + + +
+ ); + })} +
+
+ )} + + {/* ── Feature Data header ── */} +
+ + Feature Data + + +
+ + {/* ── Feature Data tree ── */} + {loadingFeatures && featureTree.length === 0 && ( +
+ Loading feature instances... +
+ )} + + {!loadingFeatures && featureTree.length === 0 && ( +
+ No feature instances found. +
+ )} + + {featureTree.map(g => ( + <_MandateGroupView + key={g.mandateId} + group={g} + onToggleGroup={_toggleMandateGroup} + onToggleFeature={_toggleFeatureNode} + onAddTable={_addFeatureTable} + isTableAdded={_isFeatureTableAdded} + addingKey={addingFeatureKey} + /> + ))} +
+ ); +}; + +/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ + +interface _TreeNodeViewProps { + node: TreeNode; + depth: number; + onToggle: (node: TreeNode) => void; + onAdd: (node: TreeNode) => void; + isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; + addingPath: string | null; +} + +const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ + node, depth, onToggle, onAdd, isAdded, addingPath, +}) => { + const [hovered, setHovered] = useState(false); + const hasChildren = node.type !== 'file'; + const chevron = hasChildren + ? (node.expanded ? '\u25BE' : '\u25B8') + : '\u00A0\u00A0'; + const canAdd = node.type === 'folder' || node.type === 'service'; + const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); + const isAdding = addingPath === node.key; + + return ( +
+
{ if (hasChildren) onToggle(node); }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + paddingLeft: depth * 16 + 4, + paddingRight: 4, + paddingTop: 3, + paddingBottom: 3, + cursor: hasChildren ? 'pointer' : 'default', + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', + userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + {node.icon} + + {node.label} + + {canAdd && hovered && !alreadyAdded && ( + + )} + {canAdd && alreadyAdded && ( + + {'\u2713'} + + )} +
+ + {node.expanded && node.children && node.children.length > 0 && ( +
+ {node.children.map(child => ( + <_TreeNodeView + key={child.key} + node={child} + depth={depth + 1} + onToggle={onToggle} + onAdd={onAdd} + isAdded={isAdded} + addingPath={addingPath} + /> + ))} +
+ )} + + {node.expanded && node.children && node.children.length === 0 && !node.loading && ( +
+ (empty) +
+ )} +
+ ); +}; + +/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ + +interface _MandateGroupViewProps { + group: MandateGroupNode; + onToggleGroup: (mandateId: string) => void; + onToggleFeature: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; +} + +const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ + group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = group.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
onToggleGroup(group.mandateId)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {chevron} + + + {group.mandateLabel} + +
+ + {group.expanded && ( +
+ {group.featureConnections.map(fNode => ( + <_FeatureNodeView + key={fNode.featureInstanceId} + node={fNode} + onToggle={onToggleFeature} + onAddTable={onAddTable} + isTableAdded={isTableAdded} + addingKey={addingKey} + /> + ))} +
+ )} +
+ ); +}; + +/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ + +interface _FeatureNodeViewProps { + node: FeatureConnectionNode; + onToggle: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; +} + +const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ + node, onToggle, onAddTable, isTableAdded, addingKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = node.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
onToggle(node)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + + {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {node.label} + + + {node.tableCount} tables + +
+ + {node.expanded && node.tables && node.tables.length > 0 && ( +
+ {node.tables.map(table => ( + <_FeatureTableRow + key={table.objectKey} + featureNode={node} + table={table} + onAdd={onAddTable} + isAdded={isTableAdded(node.featureInstanceId, table.tableName)} + isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + /> + ))} +
+ )} + + {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( +
+ (no tables) +
+ )} +
+ ); +}; + +/* ─── FeatureTableRow ────────────────────────────────────────────────── */ + +interface _FeatureTableRowProps { + featureNode: FeatureConnectionNode; + table: FeatureTableNode; + onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isAdded: boolean; + isAdding: boolean; +} + +const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ + featureNode, table, onAdd, isAdded, isAdding, +}) => { + const [hovered, setHovered] = useState(false); + const tableLabel = table.label?.en || table.label?.de || table.tableName; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + title={`${table.tableName}: ${table.fields.join(', ')}`} + > + {'\uD83D\uDCC1'} + + {tableLabel} + + {hovered && !isAdded && ( + + )} + {isAdded && ( + + {'\u2713'} + + )}
); }; diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 00ae85f..8a6ddc9 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -1,4 +1,7 @@ import React, { useState } from 'react'; +import ChatsTab from './ChatsTab'; +import FilesTab from './FilesTab'; +import SourcesTab from './SourcesTab'; import styles from './UnifiedDataBar.module.css'; export type UdbTab = 'chats' | 'files' | 'sources'; @@ -14,10 +17,14 @@ 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; + hideTabs?: UdbTab[]; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + activeWorkflowId?: string; + onCreateNewChat?: () => void; + onRenameChat?: (chatId: string, newName: string) => void; + onDeleteChat?: (chatId: string) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + onFileSelect?: (fileId: string) => void; className?: string; } @@ -31,12 +38,20 @@ const UnifiedDataBar: React.FC = ({ context, activeTab: controlledTab, onTabChange, - renderChats, - renderFiles, - renderSources, + hideTabs, + onSelectChat, + activeWorkflowId, + onCreateNewChat, + onRenameChat, + onDeleteChat, + onChatDragStart, + onFileSelect, className, }) => { - const [internalTab, setInternalTab] = useState('chats'); + const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter( + t => !hideTabs?.includes(t), + ); + const [internalTab, setInternalTab] = useState(controlledTab ?? visibleTabs[0] ?? 'chats'); const currentTab = controlledTab ?? internalTab; const _handleTabChange = (tab: UdbTab) => { @@ -47,7 +62,7 @@ const UnifiedDataBar: React.FC = ({ return (
- {(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => ( + {visibleTabs.map((tab) => (
- {currentTab === 'chats' && renderChats?.(context)} - {currentTab === 'files' && renderFiles?.(context)} - {currentTab === 'sources' && renderSources?.(context)} + {currentTab === 'chats' && !hideTabs?.includes('chats') && ( + + )} + {currentTab === 'files' && !hideTabs?.includes('files') && ( + + )} + {currentTab === 'sources' && !hideTabs?.includes('sources') && ( + + )}
); diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts index bb63a3a..83b7dfc 100644 --- a/src/components/UnifiedDataBar/index.ts +++ b/src/components/UnifiedDataBar/index.ts @@ -1,6 +1,3 @@ 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/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index c72d8da..2eaf47b 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -66,6 +66,7 @@ export interface FeatureInstance { uiLabel: string; order: number; views: FeatureView[]; + isAdmin?: boolean; } /** Feature within a mandate */ diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7ff80d0..d3ad680 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -7,11 +7,12 @@ */ import React from 'react'; -import { Link, Navigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import useNavigation from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import { getPageIcon } from '../config/pageRegistry'; import { FaArrowRight, FaBuilding } from 'react-icons/fa'; +import OnboardingAssistant from '../components/OnboardingAssistant'; import styles from './Dashboard.module.css'; // ============================================================================= @@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => { ); } - if (totalInstances === 0) { - return ; - } - return (

Übersicht

-

- Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. -

+ {totalInstances > 0 && ( +

+ Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. +

+ )}
+ +
{mandates .filter(mandate => mandate.features.some(f => f.instances.length > 0)) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8a8ae74..5b4cf08 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,12 +17,13 @@ import styles from './Settings.module.css'; // TYPES // ============================================================================= -type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'profile', label: 'Profil' }, { key: 'appearance', label: 'Darstellung' }, { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'neutralization', label: 'Datenneutralisierung' }, { key: 'privacy', label: 'Datenschutz' }, ]; @@ -296,6 +297,116 @@ const VoiceSettingsTab: React.FC = () => { ); }; +// ============================================================================= +// NEUTRALIZATION MAPPINGS TAB +// ============================================================================= + +interface NeutralizationMapping { + id: string; + originalText: string; + patternType: string; + fileId?: string; + featureInstanceId?: string; +} + +const NeutralizationMappingsTab: React.FC = () => { + const { request } = useApiRequest(); + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const _load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' }); + const items = (result?.mappings || []).map((m: any) => ({ + id: m.id, + originalText: m.originalText || '', + patternType: m.patternType || '', + fileId: m.fileId, + featureInstanceId: m.featureInstanceId, + })); + setMappings(items); + } catch (err: any) { + setError(err.message || 'Fehler beim Laden'); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _load(); }, [_load]); + + const _handleDelete = useCallback(async (id: string) => { + try { + await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' }); + setMappings(prev => prev.filter(m => m.id !== id)); + } catch (err: any) { + setError(err.message || 'Fehler beim Loeschen'); + } + }, [request]); + + const _maskText = (text: string) => { + if (text.length <= 4) return '****'; + return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2); + }; + + if (loading) return
Mappings werden geladen...
; + + return ( + <> + {error &&
{error}
} + +
+

Platzhalter-Mappings

+

+ Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt. + Hier sehen Sie Ihre gespeicherten Mappings und koennen sie loeschen. +

+ + {mappings.length === 0 ? ( +
+ Keine Neutralisierungs-Mappings vorhanden. +
+ ) : ( + + + + + + + + + + {mappings.map(m => ( + + + + + + + ))} + +
Platzhalter-IDOriginaltextTyp +
{m.id.slice(0, 12)}...{_maskText(m.originalText)} + + {m.patternType} + + + +
+ )} +
+ + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -421,6 +532,8 @@ export const SettingsPage: React.FC = () => { {activeTab === 'voice' && } + {activeTab === 'neutralization' && } + {activeTab === 'privacy' && (

Datenschutz

diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index a6e1897..d12f7e4 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -211,6 +211,9 @@ /* Actions */ .cardActions { + display: flex; + flex-direction: column; + gap: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-color, #e0e0e0); } diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index c4f901b..1162d26 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -1,12 +1,10 @@ /** - * Store Page - * - * Feature Store where users can self-activate features in the root mandate. - * Uses the Shared Instance Pattern -- each feature has one shared instance, - * and users get their own FeatureAccess + user-role upon activation. + * Feature Store -- Users activate feature instances in their own mandates. + * Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance + * in the selected mandate. Explicit mandate selection required. */ -import React, { useState } from 'react'; +import React from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; @@ -76,22 +74,10 @@ const FeatureCard: React.FC = ({ 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 (
@@ -142,43 +128,22 @@ const FeatureCard: React.FC = ({ )}
- {feature.canActivate && ( - <> - {mandates.length === 0 && ( -

- {language === 'de' - ? 'Ein persoenliches Konto wird automatisch erstellt.' + {feature.canActivate && mandates.map((m) => ( + - - )} + ? `Activer pour ${m.label || m.name}` + : `Activate for ${m.label || m.name}`)} + + ))}

); diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 5e0b662..0a027b8 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -228,7 +228,7 @@ export const ConnectionsPage: React.FC = () => {

Verbindungen

-

OAuth-Verbindungen verwalten

+

Persönliche Datenanbindungen verwalten

@@ -523,6 +523,8 @@ export const CommcoachDossierView: React.FC = () => { )} )} + + {/* #region agent log */}
))}
+ {(msg as any).neutralizationExcluded?.length > 0 && ( +
+
+ Nicht gesendet (Neutralisierung fehlgeschlagen): +
+ {(msg as any).neutralizationExcluded.map((docName: string, i: number) => ( +
+ {docName} +
+ ))} +
+ )} )}
diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx deleted file mode 100644 index c70f5f1..0000000 --- a/src/pages/views/workspace/ConversationList.tsx +++ /dev/null @@ -1,438 +0,0 @@ -/** - * ConversationList -- Shows all workspace workflows/conversations. - * - * Features: filter, rename (double-click), delete, archive, create new, - * pagination (20 per page), last-activity display. - */ - -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import api from '../../../api'; - -const _PAGE_SIZE = 20; - -interface Conversation { - id: string; - name: string; - status: string; - startedAt?: number; - lastActivity?: number; -} - -interface ConversationListProps { - instanceId: string; - activeWorkflowId: string | null; - onSelect: (workflowId: string) => void; - onCreateNew?: () => void; - refreshTrigger?: number; -} - -export const ConversationList: React.FC = ({ - instanceId, - activeWorkflowId, - onSelect, - onCreateNew, - refreshTrigger, -}) => { - const [conversations, setConversations] = useState([]); - const [loading, setLoading] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [filterQuery, setFilterQuery] = useState(''); - const [page, setPage] = useState(0); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [viewMode, setViewMode] = useState<'active' | 'archived'>('active'); - const inputRef = useRef(null); - - const _loadConversations = useCallback(() => { - if (!instanceId) return; - setLoading(true); - api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } }) - .then(res => { - const items = (res.data.workflows || res.data || []) - .map((w: any) => ({ - id: w.id, - name: w.name || w.label || 'Untitled', - status: w.status || 'unknown', - startedAt: w.startedAt || w.createdAt, - lastActivity: w.lastActivity || w.updatedAt || w.startedAt, - })) - .sort((a: Conversation, b: Conversation) => - (b.lastActivity || 0) - (a.lastActivity || 0), - ); - setConversations(items); - }) - .catch(() => setConversations([])) - .finally(() => setLoading(false)); - }, [instanceId]); - - useEffect(() => { - _loadConversations(); - }, [_loadConversations]); - - useEffect(() => { - if (refreshTrigger) _loadConversations(); - }, [refreshTrigger, _loadConversations]); - - useEffect(() => { - if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) { - _loadConversations(); - } - }, [activeWorkflowId, conversations, _loadConversations]); - - useEffect(() => { - if (editingId && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editingId]); - - const _formatTime = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - if (diffDays === 1) return 'Gestern'; - if (diffDays < 7) return `vor ${diffDays}d`; - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); - }; - - const _formatDate = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) - + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }; - - const _startEditing = (conv: Conversation) => { - setEditingId(conv.id); - setEditName(conv.name); - }; - - const _commitRename = (convId: string) => { - const trimmed = editName.trim(); - if (!trimmed) { - setEditingId(null); - return; - } - setConversations(prev => - prev.map(c => c.id === convId ? { ...c, name: trimmed } : c), - ); - setEditingId(null); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed }) - .catch(() => _loadConversations()); - }; - - const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => { - if (e.key === 'Enter') { - e.preventDefault(); - _commitRename(convId); - } else if (e.key === 'Escape') { - setEditingId(null); - } - }; - - const _handleDelete = (convId: string) => { - setConversations(prev => prev.filter(c => c.id !== convId)); - if (activeWorkflowId === convId) onSelect(''); - api.delete(`/api/workspace/${instanceId}/workflows/${convId}`) - .catch(() => _loadConversations()); - }; - - const _handleArchive = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'archived' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' }) - .catch(() => _loadConversations()); - }; - - const _handleReactivate = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'active' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' }) - .catch(() => _loadConversations()); - }; - - const _handleCreateNew = () => { - if (onCreateNew) onCreateNew(); - }; - - const _filtered = (items: Conversation[], query: string): Conversation[] => { - if (!query.trim()) return items; - const q = query.toLowerCase(); - return items.filter(c => - c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q), - ); - }; - - const _byStatus = viewMode === 'archived' - ? conversations.filter(c => c.status === 'archived') - : conversations.filter(c => c.status !== 'archived'); - const filtered = _filtered(_byStatus, filterQuery); - const totalPages = Math.ceil(filtered.length / _PAGE_SIZE); - const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE); - - const _archivedCount = conversations.filter(c => c.status === 'archived').length; - const _activeCount = conversations.filter(c => c.status !== 'archived').length; - - useEffect(() => { setPage(0); }, [filterQuery, viewMode]); - - return ( -
- {/* Header */} -
- Conversations -
- - -
-
- - {/* View mode toggle */} -
- - -
- - {/* Filter */} - {filtered.length > 3 && ( - setFilterQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', - }} - /> - )} - - {/* Empty state */} - {filtered.length === 0 && !loading && ( -
- {viewMode === 'archived' - ? 'Keine archivierten Chats.' - : 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'} -
- )} - - {/* List */} - {paginated.map(conv => { - const isActive = conv.id === activeWorkflowId; - const isEditing = editingId === conv.id; - return ( -
{ if (!isEditing) onSelect(conv.id); }} - style={{ - padding: '8px 10px', - marginBottom: 4, - borderRadius: 6, - cursor: isEditing ? 'default' : 'pointer', - background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent', - border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent', - transition: 'background 0.15s', - position: 'relative', - }} - onMouseEnter={e => { - if (!isActive) e.currentTarget.style.background = '#f5f5f5'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '1'; - }} - onMouseLeave={e => { - if (!isActive) e.currentTarget.style.background = 'transparent'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '0'; - if (confirmDeleteId === conv.id) setConfirmDeleteId(null); - }} - > - {/* Name row */} -
- {isEditing ? ( - setEditName(e.target.value)} - onBlur={() => _commitRename(conv.id)} - onKeyDown={e => _handleKeyDown(e, conv.id)} - onClick={e => e.stopPropagation()} - style={{ - flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, - padding: '1px 4px', borderRadius: 3, - border: '1px solid var(--primary-color, #1976d2)', - outline: 'none', background: '#fff', - }} - /> - ) : ( - <> - - {_formatTime(conv.lastActivity)} - - { e.stopPropagation(); _startEditing(conv); }} - title={conv.name} - > - {conv.name} - - - )} - - {/* Action buttons (visible on hover) */} - {!isEditing && ( - - - {conv.status === 'archived' ? ( - - ) : ( - - )} - {confirmDeleteId === conv.id ? ( - - - - - ) : ( - - )} - - )} -
- -
- ); - })} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {page + 1} / {totalPages} - -
- )} -
- ); -}; - -const _actionBtnStyle: React.CSSProperties = { - background: 'none', - border: 'none', - cursor: 'pointer', - fontSize: 11, - color: '#999', - padding: '0 2px', -}; - -const _pageBtnStyle: React.CSSProperties = { - background: 'none', - border: '1px solid #ddd', - borderRadius: 4, - cursor: 'pointer', - padding: '2px 8px', - color: '#666', -}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx deleted file mode 100644 index e343792..0000000 --- a/src/pages/views/workspace/DataSourcePanel.tsx +++ /dev/null @@ -1,942 +0,0 @@ -/** - * DataSourcePanel -- Browse external data sources as a lazy-loading tree. - * - * Tree structure: - * UserConnection (Level 1, loaded on mount) - * └─ Service (Level 2, loaded when connection expanded) - * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) - * - * Each folder node can be added as a DataSource for this workspace instance. - */ - -import React, { useEffect, useState, useCallback, useRef } from 'react'; -import api from '../../../api'; -import { getPageIcon } from '../../../config/pageRegistry'; -import type { DataSource, FeatureDataSource } from './useWorkspace'; - -/* ─── Types ─────────────────────────────────────────────────────────── */ - -interface TreeNode { - key: string; - label: string; - icon: string; - type: 'connection' | 'service' | 'folder' | 'file'; - expanded: boolean; - loading: boolean; - children: TreeNode[] | null; - connectionId: string; - service?: string; - path?: string; - /** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */ - displayPath?: string; - authority?: string; -} - -interface FeatureConnectionNode { - featureInstanceId: string; - featureCode: string; - mandateId?: string; - label: string; - icon: string; - tableCount: number; - expanded: boolean; - loading: boolean; - tables: FeatureTableNode[] | null; -} - -interface MandateGroupNode { - mandateId: string; - mandateLabel: string; - expanded: boolean; - featureConnections: FeatureConnectionNode[]; -} - -interface FeatureTableNode { - objectKey: string; - tableName: string; - label: Record; - fields: string[]; -} - -interface DataSourcePanelProps { - instanceId: string; - dataSources: DataSource[]; - featureDataSources: FeatureDataSource[]; - onRefresh: () => void; - onRefreshFeatureDataSources: () => void; -} - -/* ─── Icons ─────────────────────────────────────────────────────────── */ - -const _AUTHORITY_ICONS: Record = { - msft: '\uD83D\uDFE6', - google: '\uD83D\uDFE9', - 'local:ftp': '\uD83D\uDD17', - 'local:jira': '\uD83D\uDD27', -}; - -const _SERVICE_ICONS: Record = { - sharepoint: '\uD83D\uDCC1', - onedrive: '\u2601\uFE0F', - outlook: '\uD83D\uDCE7', - teams: '\uD83D\uDCAC', - drive: '\uD83D\uDCC2', - gmail: '\uD83D\uDCE8', - files: '\uD83D\uDCC2', -}; - -/* ─── Source colors & icons ──────────────────────────────────────────── */ - -const _SOURCE_COLORS: Record = { - sharepointFolder: '#0078d4', - onedriveFolder: '#0078d4', - outlookFolder: '#0078d4', - googleDriveFolder: '#34a853', - gmailFolder: '#ea4335', - ftpFolder: '#795548', -}; - -function _getSourceColor(sourceType: string): string { - return _SOURCE_COLORS[sourceType] || '#1976d2'; -} - -function _getSourceIcon(sourceType: string): string { - const map: Record = { - sharepointFolder: '\uD83D\uDCC1', - onedriveFolder: '\u2601\uFE0F', - outlookFolder: '\uD83D\uDCE7', - googleDriveFolder: '\uD83D\uDCC2', - gmailFolder: '\uD83D\uDCE8', - ftpFolder: '\uD83D\uDD17', - }; - return map[sourceType] || '\uD83D\uDCC1'; -} - -function _mapFeatureTreeUpdate( - prev: MandateGroupNode[], - featureInstanceId: string, - updater: (n: FeatureConnectionNode) => FeatureConnectionNode, -): MandateGroupNode[] { - return prev.map(g => ({ - ...g, - featureConnections: g.featureConnections.map(n => - n.featureInstanceId === featureInstanceId ? updater(n) : n - ), - })); -} - -function _findFeatureInstanceMeta( - groups: MandateGroupNode[], - featureInstanceId: string, -): { mandateLabel: string; instanceLabel: string } | null { - for (const g of groups) { - const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); - if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; - } - return null; -} - -function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string { - const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; - return pathPart ? `${connLabel} / ${pathPart}` : connLabel; -} - -function _featureDataSourceHoverTitle( - meta: { mandateLabel: string; instanceLabel: string } | null, - fds: FeatureDataSource, -): string { - const parts: string[] = []; - if (meta) { - parts.push(meta.mandateLabel, meta.instanceLabel); - } - const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName - ? `${fds.label} (${fds.tableName})` - : (fds.label || fds.tableName); - parts.push(labelPart); - if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { - parts.push(fds.objectKey); - } - return parts.join(' / '); -} - -/* ─── Component ─────────────────────────────────────────────────────── */ - -export const DataSourcePanel: React.FC = ({ - instanceId, - dataSources, - featureDataSources, - onRefresh, - onRefreshFeatureDataSources, -}) => { - const [tree, setTree] = useState([]); - const [loadingRoot, setLoadingRoot] = useState(false); - const [addingPath, setAddingPath] = useState(null); - const [featureTree, setFeatureTree] = useState([]); - const [loadingFeatures, setLoadingFeatures] = useState(false); - const [addingFeatureKey, setAddingFeatureKey] = useState(null); - const mountedRef = useRef(true); - useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); - - /* ── Load Level 1: UserConnections ── */ - const _loadConnections = useCallback(() => { - if (!instanceId) return; - setLoadingRoot(true); - api.get(`/api/workspace/${instanceId}/connections`) - .then(res => { - if (!mountedRef.current) return; - const conns = res.data.connections || []; - const nodes: TreeNode[] = conns - .filter((c: any) => c.status === 'active') - .map((c: any) => ({ - key: `conn-${c.id}`, - label: c.externalEmail || c.externalUsername || c.authority, - icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', - type: 'connection' as const, - expanded: false, - loading: false, - children: null, - connectionId: c.id, - authority: c.authority, - })); - setTree(nodes); - }) - .catch(() => { if (mountedRef.current) setTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); - }, [instanceId]); - - useEffect(() => { _loadConnections(); }, [_loadConnections]); - - /* ── Generic tree update helper ── */ - const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { - setTree(prev => _mapTree(prev, key, updater)); - }, []); - - /* ── Toggle expand/collapse ── */ - const _toggleNode = useCallback(async (node: TreeNode) => { - if (node.expanded) { - _updateNode(node.key, n => ({ ...n, expanded: false })); - return; - } - - if (node.children !== null) { - _updateNode(node.key, n => ({ ...n, expanded: true })); - return; - } - - _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); - - try { - let children: TreeNode[] = []; - - if (node.type === 'connection') { - children = await _loadServices(instanceId, node.connectionId); - } else if (node.type === 'service' || node.type === 'folder') { - children = await _browseService( - instanceId, - node.connectionId, - node.service!, - node.path || '/', - node.displayPath || node.label, - ); - } - - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children })); - } - } catch { - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); - } - } - }, [instanceId, _updateNode]); - - /* ── Add as DataSource ── */ - const _addAsDataSource = useCallback(async (node: TreeNode) => { - if (!node.service || !node.connectionId) return; - setAddingPath(node.key); - try { - const sourceTypeMap: Record = { - sharepoint: 'sharepointFolder', - onedrive: 'onedriveFolder', - outlook: 'outlookFolder', - drive: 'googleDriveFolder', - gmail: 'gmailFolder', - files: 'ftpFolder', - }; - await api.post(`/api/workspace/${instanceId}/datasources`, { - connectionId: node.connectionId, - sourceType: sourceTypeMap[node.service] || node.service, - path: node.path || '/', - label: node.label, - displayPath: node.displayPath || node.label, - }); - onRefresh(); - } catch (err) { - console.error('Failed to add data source:', err); - } finally { - if (mountedRef.current) setAddingPath(null); - } - }, [instanceId, onRefresh]); - - /* ── Remove DataSource ── */ - const _removeDatasource = useCallback(async (dsId: string) => { - try { - await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); - onRefresh(); - } catch (err) { - console.error('Failed to remove data source:', err); - } - }, [instanceId, onRefresh]); - - /* ── Check if a path is already added ── */ - const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { - return dataSources.some(ds => - ds.connectionId === connectionId && ds.path === (path || '/'), - ); - }, [dataSources]); - - /* ── Feature Connections: Load Level 1 ── */ - const _loadFeatureConnections = useCallback(() => { - if (!instanceId) return; - setLoadingFeatures(true); - api.get(`/api/workspace/${instanceId}/feature-connections`) - .then(res => { - if (!mountedRef.current) return; - const groups = res.data.featureConnectionsByMandate || []; - setFeatureTree(groups.map((g: any) => ({ - mandateId: g.mandateId, - mandateLabel: g.mandateLabel || g.mandateId, - expanded: true, - featureConnections: (g.featureConnections || []).map((c: any) => ({ - featureInstanceId: c.featureInstanceId, - featureCode: c.featureCode, - mandateId: c.mandateId, - label: c.label, - icon: c.icon || '\uD83D\uDDC3\uFE0F', - tableCount: c.tableCount || 0, - expanded: false, - loading: false, - tables: null, - })), - }))); - }) - .catch(() => { if (mountedRef.current) setFeatureTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); - }, [instanceId]); - - useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); - - /* ── Feature Connections: Toggle mandate group ── */ - const _toggleMandateGroup = useCallback((mandateId: string) => { - setFeatureTree(prev => prev.map(g => - g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g - )); - }, []); - - /* ── Feature Connections: Toggle expand ── */ - const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { - if (node.expanded) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); - return; - } - - if (node.tables !== null) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); - return; - } - - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: true, expanded: true, - }))); - - try { - const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); - const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ - objectKey: t.objectKey, - tableName: t.tableName, - label: t.label || {}, - fields: t.fields || [], - })); - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables, - }))); - } - } catch { - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables: [], - }))); - } - } - }, [instanceId]); - - /* ── Feature: Add table as FeatureDataSource ── */ - const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { - const key = `${node.featureInstanceId}-${table.tableName}`; - setAddingFeatureKey(key); - try { - await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: table.tableName, - objectKey: table.objectKey, - label: table.label?.en || table.label?.de || table.tableName, - }); - onRefreshFeatureDataSources(); - } catch (err) { - console.error('Failed to add feature data source:', err); - } finally { - if (mountedRef.current) setAddingFeatureKey(null); - } - }, [instanceId, onRefreshFeatureDataSources]); - - /* ── Feature: Remove FeatureDataSource ── */ - const _removeFeatureDataSource = useCallback(async (fdsId: string) => { - try { - await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); - onRefreshFeatureDataSources(); - } catch (err) { - console.error('Failed to remove feature data source:', err); - } - }, [instanceId, onRefreshFeatureDataSources]); - - /* ── Feature: check if table already added ── */ - const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { - return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, - ); - }, [featureDataSources]); - - return ( -
- {/* Active DataSources */} - {dataSources.length > 0 && ( -
-
- Active Personal Sources -
- {dataSources.map(ds => { - const connColor = _getSourceColor(ds.sourceType); - const connNode = tree.find(n => n.connectionId === ds.connectionId); - const connLabel = connNode?.label || ds.connectionId; - const folder = ds.label || ds.path || ds.id; - return ( -
- {_getSourceIcon(ds.sourceType)} - - {connLabel} – {folder} - - -
- ); - })} -
-
- )} - - {/* Tree header */} -
- - Browse Sources - - -
- - {/* Tree */} - {loadingRoot && tree.length === 0 && ( -
- Loading connections... -
- )} - - {!loadingRoot && tree.length === 0 && ( -
- No active connections found. -
- )} - - {tree.map(node => ( - <_TreeNodeView - key={node.key} - node={node} - depth={0} - onToggle={_toggleNode} - onAdd={_addAsDataSource} - isAdded={_isAdded} - addingPath={addingPath} - /> - ))} - - {/* ── Feature Data Section ── */} -
- - {/* Active Feature Data Sources */} - {featureDataSources.length > 0 && ( -
-
- Active Feature Sources -
- {featureDataSources.map(fds => { - const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); - const fdsConnLabel = meta?.instanceLabel || fds.tableName; - return ( -
- - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fdsConnLabel} – {fds.tableName} - - -
- ); })} -
-
- )} - - {/* Feature Connections Tree */} -
- - Feature Data - - -
- - {loadingFeatures && featureTree.length === 0 && ( -
- Loading feature instances... -
- )} - - {!loadingFeatures && featureTree.length === 0 && ( -
- No feature instances found. -
- )} - - {featureTree.map(g => ( - <_MandateGroupView - key={g.mandateId} - group={g} - onToggleGroup={_toggleMandateGroup} - onToggleFeature={_toggleFeatureNode} - onAddTable={_addFeatureTable} - isTableAdded={_isFeatureTableAdded} - addingKey={addingFeatureKey} - /> - ))} -
- ); -}; - -/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ - -interface TreeNodeViewProps { - node: TreeNode; - depth: number; - onToggle: (node: TreeNode) => void; - onAdd: (node: TreeNode) => void; - isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; - addingPath: string | null; -} - -const _TreeNodeView: React.FC = ({ - node, depth, onToggle, onAdd, isAdded, addingPath, -}) => { - const [hovered, setHovered] = useState(false); - const hasChildren = node.type !== 'file'; - const chevron = hasChildren - ? (node.expanded ? '\u25BE' : '\u25B8') - : '\u00A0\u00A0'; - const canAdd = node.type === 'folder' || node.type === 'service'; - const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); - const isAdding = addingPath === node.key; - - return ( -
-
{ if (hasChildren) onToggle(node); }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 4, - paddingLeft: depth * 16 + 4, - paddingRight: 4, - paddingTop: 3, - paddingBottom: 3, - cursor: hasChildren ? 'pointer' : 'default', - borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', - userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - {node.icon} - - {node.label} - - {canAdd && hovered && !alreadyAdded && ( - - )} - {canAdd && alreadyAdded && ( - - {'\u2713'} - - )} -
- - {/* Children */} - {node.expanded && node.children && node.children.length > 0 && ( -
- {node.children.map(child => ( - <_TreeNodeView - key={child.key} - node={child} - depth={depth + 1} - onToggle={onToggle} - onAdd={onAdd} - isAdded={isAdded} - addingPath={addingPath} - /> - ))} -
- )} - - {node.expanded && node.children && node.children.length === 0 && !node.loading && ( -
- (empty) -
- )} -
- ); -}; - -/* ─── MandateGroupView (mandate + feature instances) ───────────────── */ - -interface MandateGroupViewProps { - group: MandateGroupNode; - onToggleGroup: (mandateId: string) => void; - onToggleFeature: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; -} - -const _MandateGroupView: React.FC = ({ - group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, -}) => { - const [hovered, setHovered] = useState(false); - const chevron = group.expanded ? '\u25BE' : '\u25B8'; - - return ( -
-
onToggleGroup(group.mandateId)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {chevron} - - - {group.mandateLabel} - -
- - {group.expanded && ( -
- {group.featureConnections.map(fNode => ( - <_FeatureNodeView - key={fNode.featureInstanceId} - node={fNode} - onToggle={onToggleFeature} - onAddTable={onAddTable} - isTableAdded={isTableAdded} - addingKey={addingKey} - /> - ))} -
- )} -
- ); -}; - -/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */ - -interface FeatureNodeViewProps { - node: FeatureConnectionNode; - onToggle: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; -} - -const _FeatureNodeView: React.FC = ({ - node, onToggle, onAddTable, isTableAdded, addingKey, -}) => { - const [hovered, setHovered] = useState(false); - const chevron = node.expanded ? '\u25BE' : '\u25B8'; - - return ( -
-
onToggle(node)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - - {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {node.label} - - - {node.tableCount} tables - -
- - {node.expanded && node.tables && node.tables.length > 0 && ( -
- {node.tables.map(table => ( - <_FeatureTableRow - key={table.objectKey} - featureNode={node} - table={table} - onAdd={onAddTable} - isAdded={isTableAdded(node.featureInstanceId, table.tableName)} - isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} - /> - ))} -
- )} - - {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( -
- (no tables) -
- )} -
- ); -}; - -interface FeatureTableRowProps { - featureNode: FeatureConnectionNode; - table: FeatureTableNode; - onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isAdded: boolean; - isAdding: boolean; -} - -const _FeatureTableRow: React.FC = ({ - featureNode, table, onAdd, isAdded, isAdding, -}) => { - const [hovered, setHovered] = useState(false); - const tableLabel = table.label?.en || table.label?.de || table.tableName; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - title={`${table.tableName}: ${table.fields.join(', ')}`} - > - {'\uD83D\uDCC1'} - - {tableLabel} - - {hovered && !isAdded && ( - - )} - {isAdded && ( - - {'\u2713'} - - )} -
- ); -}; - -/* ─── Spinner (inline) ──────────────────────────────────────────────── */ - -function _Spinner(): React.ReactElement { - return ( - - ); -} - -/* ─── Data fetching ─────────────────────────────────────────────────── */ - -async function _loadServices(instanceId: string, connectionId: string): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); - const services = res.data.services || []; - return services.map((s: any) => ({ - key: `svc-${connectionId}-${s.service}`, - label: s.label || s.service, - icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', - type: 'service' as const, - expanded: false, - loading: false, - children: null, - connectionId, - service: s.service, - path: '/', - displayPath: s.label || s.service, - })); -} - -async function _browseService( - instanceId: string, - connectionId: string, - service: string, - path: string, - parentDisplayPath: string | undefined, -): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { - params: { service, path }, - }); - const items = res.data.items || []; - return items.map((entry: any, idx: number) => { - const seg = entry.name || ''; - const displayPath = parentDisplayPath - ? `${parentDisplayPath} / ${seg}` - : seg; - return { - key: `item-${connectionId}-${service}-${entry.path || idx}`, - label: entry.name, - icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), - type: entry.isFolder ? 'folder' as const : 'file' as const, - expanded: false, - loading: false, - children: entry.isFolder ? null : [], - connectionId, - service, - path: entry.path, - displayPath, - }; - }); -} - -function _fileIcon(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', - xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', - ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', - txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', - png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', - zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', - mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', - mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', - }; - return map[ext] || '\uD83D\uDCC4'; -} - -/* ─── Tree map utility ──────────────────────────────────────────────── */ - -function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { - return nodes.map(n => { - if (n.key === key) return updater(n); - if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; - return n; - }); -} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx deleted file mode 100644 index 1ce992c..0000000 --- a/src/pages/views/workspace/FileBrowser.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * FileBrowser -- Folder-tree file browser for workspace. - * - * Uses useFileContext() for folders (shared state with Dateien page). - * Uses FolderTree with showFiles=true so folders and files render inline. - */ - -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import api from '../../../api'; -import FolderTree from '../../../components/FolderTree/FolderTree'; -import type { FileNode } from '../../../components/FolderTree/FolderTree'; -import { useFileContext } from '../../../contexts/FileContext'; -import type { WorkspaceFile } from './useWorkspace'; - -interface FileBrowserProps { - instanceId: string; - files: WorkspaceFile[]; - onRefresh: () => void; - onFileSelect?: (fileId: string) => void; -} - -export const FileBrowser: React.FC = ({ - instanceId, - files, - onRefresh, - onFileSelect, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isDragOver, setIsDragOver] = useState(false); - const [uploading, setUploading] = useState(false); - const [selectedFolderId, setSelectedFolderId] = useState(null); - const fileInputRef = useRef(null); - - const { - folders, - refreshFolders, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFolders, - handleMoveFile, - handleMoveFiles: contextMoveFiles, - handleFileDelete, - handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, - } = useFileContext(); - - const _folderNodes = useMemo(() => - folders.map(f => ({ - id: f.id, - name: f.name, - parentId: f.parentId ?? null, - })), - [folders], - ); - - const _fileNodes: FileNode[] = useMemo(() => { - let result: WorkspaceFile[] = files; - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter(f => - f.fileName.toLowerCase().includes(q) - || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), - ); - } - return result - .sort((a, b) => a.fileName.localeCompare(b.fileName)) - .map(f => ({ - id: f.id, - fileName: f.fileName, - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - })); - }, [files, searchQuery]); - - const _refreshAll = useCallback(() => { - onRefresh(); - refreshFolders(); - }, [onRefresh, refreshFolders]); - - const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { - if (!instanceId || uploading) return; - setUploading(true); - try { - for (const file of Array.from(fileList)) { - const formData = new FormData(); - formData.append('file', file); - formData.append('featureInstanceId', instanceId); - await api.post('/api/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - } - _refreshAll(); - } catch (err) { - console.error('File upload failed:', err); - } finally { - setUploading(false); - } - }, [instanceId, uploading, _refreshAll]); - - const _handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('Files')) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - } - }, []); - - const _handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const _handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (e.dataTransfer.files.length > 0) { - _uploadFiles(e.dataTransfer.files); - } - }, [_uploadFiles]); - - const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - _uploadFiles(e.target.files); - e.target.value = ''; - } - }, [_uploadFiles]); - - const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await handleMoveFile(fileId, targetFolderId); - onRefresh(); - }, [handleMoveFile, onRefresh]); - - const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - onRefresh(); - }, [contextMoveFiles, onRefresh]); - - const _onDeleteFolder = useCallback(async (folderId: string) => { - await handleDeleteFolder(folderId); - if (selectedFolderId === folderId) setSelectedFolderId(null); - onRefresh(); - }, [handleDeleteFolder, selectedFolderId, onRefresh]); - - const _onRenameFile = useCallback(async (fileId: string, newName: string) => { - await api.put(`/api/files/${fileId}`, { fileName: newName }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - onRefresh(); - }, [handleFileDelete, onRefresh]); - - const _onDeleteFiles = useCallback(async (fileIds: string[]) => { - await api.post('/api/files/batch-delete', { fileIds }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - refreshFolders(); - onRefresh(); - }, [refreshFolders, onRefresh]); - - return ( -
- {isDragOver && ( -
- Dateien hier ablegen -
- )} - - {/* Header */} -
- Files -
- - -
-
- - - - {/* Search */} - setSearchQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', boxSizing: 'border-box', - }} - /> - - {/* Folder tree with inline files */} - - - {_fileNodes.length === 0 && ( -
- {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} -
- )} -
- ); -}; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9d91a75..9b16849 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -38,7 +38,7 @@ interface TreeItemDrop { interface WorkspaceInputProps { instanceId: string; - onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void; + onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void; isProcessing: boolean; onStop: () => void; files: WorkspaceFile[]; @@ -84,6 +84,7 @@ export const WorkspaceInput: React.FC = ({ const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); + const [neutralizeActive, setNeutralizeActive] = useState(false); const textareaRef = useRef(null); const promptBeforeVoiceRef = useRef(''); const finalizedTextRef = useRef(''); @@ -122,12 +123,13 @@ export const WorkspaceInput: React.FC = ({ if (!trimmed || isProcessing) return; const inlineFileIds = _extractFileRefs(trimmed); const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; - onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds); + const options = neutralizeActive ? { requireNeutralization: true } : undefined; + onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); setPrompt(''); setShowAutocomplete(false); setShowSourcePicker(false); setAttachedFileIds([]); - }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]); + }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); const _handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -705,6 +707,21 @@ export const WorkspaceInput: React.FC = ({ )}
+ + {isProcessing ? (
)} -