- 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.
+
+ ) : (
+
+
+
+ | Platzhalter-ID |
+ Originaltext |
+ Typ |
+ |
+
+
+
+ {mappings.map(m => (
+
+ | {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) => (
+
- )}
- {needsMandateSelection && (
-
- )}
-
- >
- )}
+ ? `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 = ({
)}