/** * 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'; import { useLanguage } from '../../providers/language/LanguageContext'; /* ─── 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; neutralizeFields?: string[]; recordFilter?: Record; } 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; parentRecords: Record; } interface MandateGroupNode { mandateId: string; mandateLabel: string; expanded: boolean; featureConnections: FeatureConnectionNode[]; } interface FeatureTableNode { objectKey: string; tableName: string; label: string; fields: string[]; isParent?: boolean; parentTable?: string; parentKey?: string; displayFields?: string[]; } interface ParentRecordNode { id: string; displayLabel: string; fields: Record; tableName: string; expanded: boolean; } /* ─── Props ──────────────────────────────────────────────────────────── */ interface SourcesTabProps { context: UdbContext; onSourcesChanged?: () => void; onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; onAttachDataSource?: (dsId: string) => void; } /* ─── Icons ──────────────────────────────────────────────────────────── */ const _AUTHORITY_ICONS: Record = { msft: '\uD83D\uDFE6', google: '\uD83D\uDFE9', clickup: '\uD83D\uDCCB', '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', sharepoint: '#0078d4', onedriveFolder: '#0078d4', onedrive: '#0078d4', outlookFolder: '#0078d4', outlook: '#0078d4', googleDriveFolder: '#34a853', drive: '#34a853', gmailFolder: '#ea4335', gmail: '#ea4335', ftpFolder: '#795548', files: '#795548', 'local:ftp': '#795548', 'local:jira': '#0052CC', clickup: '#7b68ee', }; function _getSourceColor(sourceType: string): string { return _SOURCE_COLORS[sourceType] || '#F25843'; } /* ─── 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', }; 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]; } const _SERVICE_TO_SOURCE_TYPE: Record = { sharepoint: 'sharepointFolder', onedrive: 'onedriveFolder', outlook: 'outlookFolder', drive: 'googleDriveFolder', gmail: 'gmailFolder', files: 'ftpFolder', }; /* ─── 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 _findTableFields( groups: MandateGroupNode[], featureInstanceId: string, tableName: string, ): string[] { for (const g of groups) { const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); if (fc?.tables) { const tbl = fc.tables.find(t => t.tableName === tableName); if (tbl) return tbl.fields; } } return []; } /* ─── 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, onSourcesChanged, onSendToChat_FeatureSource, onAttachDataSource }) => { const { t } = useLanguage(); const _scopeLabel = (scope: string) => ({ personal: t('Persönlich'), featureInstance: t('Feature-Instanz'), mandate: t('Mandant'), global: t('Global'), } as Record)[scope] || scope; const _scopeCycleTitle = (scope: string) => `${t('Bereich')}: ${_scopeLabel(scope)} → ${_scopeLabel(_nextScope(scope))}`; 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); /* ── Multi-selection state for Browse-Tree ── */ const [selectedKeys, setSelectedKeys] = useState>(new Set()); const lastClickedKeyRef = useRef(null); const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => { const result: string[] = []; for (const n of nodes) { result.push(n.key); if (n.expanded && n.children) { result.push(..._flattenVisibleKeys(n.children)); } } return result; }, []); const _handleNodeSelect = useCallback((node: TreeNode, e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { setSelectedKeys(prev => { const next = new Set(prev); if (next.has(node.key)) next.delete(node.key); else next.add(node.key); return next; }); lastClickedKeyRef.current = node.key; } else if (e.shiftKey && lastClickedKeyRef.current) { const visible = _flattenVisibleKeys(tree); const a = visible.indexOf(lastClickedKeyRef.current); const b = visible.indexOf(node.key); if (a !== -1 && b !== -1) { const [start, end] = a < b ? [a, b] : [b, a]; setSelectedKeys(new Set(visible.slice(start, end + 1))); } } else { setSelectedKeys(new Set([node.key])); lastClickedKeyRef.current = node.key; } }, [tree, _flattenVisibleKeys]); 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, neutralizeFields: d.neutralizeFields || undefined, recordFilter: d.recordFilter || undefined, })); 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): Promise => { if (!node.connectionId) return null; const sourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : (node.authority || node.type); setAddingPath(node.key); try { const res = await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: node.connectionId, sourceType, path: node.path || '/', label: node.label, displayPath: node.displayPath || node.label, }); _fetchDataSources(); onSourcesChanged?.(); return res.data?.id || res.data?.dataSource?.id || null; } catch (err) { console.error('Failed to add data source:', err); return null; } 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(); onSourcesChanged?.(); } 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 => { const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined; return dataSources.some(ds => ds.connectionId === connectionId && ds.path === (path || '/') && (!expectedSourceType || ds.sourceType === expectedSourceType), ); }, [dataSources]); /* ── Send node to chat: ensure DataSource exists, then attach ── */ const _sendNodeToChat = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => { if (!onAttachDataSource) return; const expectedSourceType = params.sourceType; let ds = dataSources.find(d => d.connectionId === params.connectionId && d.path === (params.path || '/') && d.sourceType === expectedSourceType, ); if (ds) { onAttachDataSource(ds.id); return; } try { const res = await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: params.connectionId, sourceType: params.sourceType, path: params.path || '/', label: params.label, displayPath: params.displayPath || params.label, }); const newId = res.data?.id || res.data?.dataSource?.id; if (newId) { onAttachDataSource(newId); _fetchDataSources(); onSourcesChanged?.(); } } catch (err) { console.error('Failed to send data source to chat:', err); } }, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]); /* ── 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)); } }, []); /* ── Neutralize fields toggle (field-level, optimistic) ── */ const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => { const current = fds.neutralizeFields || []; const updated = current.includes(fieldName) ? current.filter(f => f !== fieldName) : [...current, fieldName]; const newFields = updated.length > 0 ? updated : undefined; setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: newFields } : d)); try { await api.patch(`/api/datasources/${fds.id}/neutralize-fields`, { neutralizeFields: newFields || [] }); } catch { setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralizeFields: fds.neutralizeFields } : 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, parentRecords: {}, })), }))); }) .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 ?? [], isParent: Boolean(t.isParent), parentTable: t.parentTable ?? null, parentKey: t.parentKey ?? null, displayFields: t.displayFields ?? [], })); 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): Promise => { const key = `${node.featureInstanceId}-${table.tableName}`; setAddingFeatureKey(key); try { const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, tableName: table.tableName, objectKey: table.objectKey, label: table.label || table.tableName, }); _fetchFeatureDataSources(); onSourcesChanged?.(); return res.data?.id || null; } catch (err) { console.error('Failed to add feature data source:', err); return null; } 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(); onSourcesChanged?.(); } 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]); /* ── Parent groups: expand/collapse + load records ── */ const [expandedParentGroups, setExpandedParentGroups] = useState>(new Set()); const [loadingParentGroup, setLoadingParentGroup] = useState(null); const [addingParentKey, setAddingParentKey] = useState(null); const _toggleParentGroup = useCallback(async (node: FeatureConnectionNode, parentTableName: string) => { const groupKey = `${node.featureInstanceId}-${parentTableName}`; if (expandedParentGroups.has(groupKey)) { setExpandedParentGroups(prev => { const next = new Set(prev); next.delete(groupKey); return next; }); return; } setExpandedParentGroups(prev => new Set(prev).add(groupKey)); if (node.parentRecords[parentTableName]) return; setLoadingParentGroup(groupKey); try { const res = await api.get( `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${parentTableName}`, ); const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({ id: r.id, displayLabel: r.displayLabel || r.id, fields: r.fields || {}, tableName: parentTableName, expanded: false, })); if (mountedRef.current) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, parentRecords: { ...n.parentRecords, [parentTableName]: records }, }))); } } catch { if (mountedRef.current) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, parentRecords: { ...n.parentRecords, [parentTableName]: [] }, }))); } } finally { if (mountedRef.current) setLoadingParentGroup(null); } }, [instanceId, expandedParentGroups]); const _toggleParentRecord = useCallback((featureInstanceId: string, parentTableName: string, recordId: string) => { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, featureInstanceId, n => ({ ...n, parentRecords: { ...n.parentRecords, [parentTableName]: (n.parentRecords[parentTableName] || []).map(r => r.id === recordId ? { ...r, expanded: !r.expanded } : r, ), }, }))); }, []); /* ── Parent record: add parent + all children with recordFilter ── */ const _addParentRecord = useCallback(async ( node: FeatureConnectionNode, parentRecord: ParentRecordNode, allTables: FeatureTableNode[], ) => { const addKey = `${node.featureInstanceId}-parent-${parentRecord.id}`; setAddingParentKey(addKey); try { const parentTable = allTables.find(t => t.tableName === parentRecord.tableName && t.isParent); const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName); if (parentTable) { const parentLabel = `${parentTable.label || parentTable.tableName}: ${parentRecord.displayLabel}`; await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, tableName: parentTable.tableName, objectKey: parentTable.objectKey, label: parentLabel, recordFilter: { id: parentRecord.id }, }); } for (const child of childTables) { const childLabel = `${child.label || child.tableName}: ${parentRecord.displayLabel}`; await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, tableName: child.tableName, objectKey: child.objectKey, label: childLabel, recordFilter: { [child.parentKey!]: parentRecord.id }, }); } _fetchFeatureDataSources(); onSourcesChanged?.(); } catch (err) { console.error('Failed to add parent record sources:', err); } finally { if (mountedRef.current) setAddingParentKey(null); } }, [instanceId, _fetchFeatureDataSources]); /* ── Check if a parent record is already added ── */ const _isParentRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => { return featureDataSources.some(fds => fds.featureInstanceId === featureInstanceId && fds.tableName === parentTableName && fds.recordFilter?.id === recordId, ); }, [featureDataSources]); /* ── Render ── */ return (
{/* ── Browse Sources header ── */}
{t('Quellen durchsuchen')}
{/* ── Browse Sources tree ── */} {loadingRoot && tree.length === 0 && (
{t('Verbindungen werden geladen…')}
)} {!loadingRoot && tree.length === 0 && (
{t('Keine aktiven Verbindungen.')}
)} {tree.map(node => ( <_TreeNodeView key={node.key} node={node} depth={0} onToggle={_toggleNode} onEnsureDs={_addAsDataSource} isAdded={_isAdded} addingPath={addingPath} dataSources={dataSources} onCycleScope={_cyclePersonalScope} onToggleNeutralize={_togglePersonalNeutralize} onRemoveDs={_removeDatasource} onSendToChat={_sendNodeToChat} scopeCycleTitle={_scopeCycleTitle} selectedKeys={selectedKeys} onSelect={_handleNodeSelect} /> ))} {/* ── Divider ── */}
{/* ── Feature Data header ── */}
{t('Feature-Daten')}
{/* ── Feature Data tree ── */} {loadingFeatures && featureTree.length === 0 && (
{t('Feature-Instanzen werden geladen…')}
)} {!loadingFeatures && featureTree.length === 0 && (
{t('Keine Feature-Instanzen gefunden.')}
)} {featureTree.map(g => ( <_MandateGroupView key={g.mandateId} group={g} onToggleGroup={_toggleMandateGroup} onToggleFeature={_toggleFeatureNode} onEnsureFds={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} onToggleParentGroup={_toggleParentGroup} onToggleParentRecord={_toggleParentRecord} onAddParentRecord={_addParentRecord} isParentRecordAdded={_isParentRecordAdded} expandedParentGroups={expandedParentGroups} loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} onSendToChat={onSendToChat_FeatureSource} featureDataSources={featureDataSources} onCycleScope={_cycleFeatureScope} onToggleNeutralize={_toggleFeatureNeutralize} onToggleNeutralizeField={_toggleNeutralizeField} onRemoveFds={_removeFeatureDataSource} featureTree={featureTree} /> ))}
); }; /* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined { const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined; return dataSources.find(ds => ds.connectionId === node.connectionId && ds.path === (node.path || '/') && (!expectedSourceType || ds.sourceType === expectedSourceType), ); } interface _TreeNodeViewProps { node: TreeNode; depth: number; onToggle: (node: TreeNode) => void; onEnsureDs: (node: TreeNode) => Promise; isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; addingPath: string | null; dataSources: UdbDataSource[]; onCycleScope: (ds: UdbDataSource) => void; onToggleNeutralize: (ds: UdbDataSource) => void; onRemoveDs: (dsId: string) => void; onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; scopeCycleTitle: (scope: string) => string; selectedKeys: Set; onSelect: (node: TreeNode, e: React.MouseEvent) => void; inheritedScope?: string; inheritedNeutralize?: boolean; } const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ node, depth, onToggle, onEnsureDs, isAdded, addingPath, dataSources, onCycleScope, onToggleNeutralize, onRemoveDs, onSendToChat, scopeCycleTitle, selectedKeys, onSelect, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const hasChildren = node.type !== 'file'; const chevron = hasChildren ? (node.expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'; const ds = _findDs(dataSources, node); const effectiveScope = ds?.scope ?? inheritedScope; const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false; const childInheritedScope = ds?.scope ?? inheritedScope; const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize; const _dragPayload = { connectionId: node.connectionId, sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', path: node.path || '/', label: node.label, displayPath: node.displayPath || node.label, nodeType: node.type, }; const _chatPayload = { connectionId: node.connectionId, sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', path: node.path || '/', label: node.label, displayPath: node.displayPath || node.label, }; const connColor = ds ? _getSourceColor(ds.sourceType) : undefined; const isSelected = selectedKeys.has(node.key); return (
{ if (e.ctrlKey || e.metaKey || e.shiftKey) { e.stopPropagation(); onSelect(node, e); } else if (hasChildren) { onToggle(node); } }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); if (selectedKeys.size > 1 && isSelected) { const items = Array.from(selectedKeys).map(k => ({ key: k, ...(_dragPayload) })); e.dataTransfer.setData('application/datasource', JSON.stringify(items)); } else { e.dataTransfer.setData('application/datasource', JSON.stringify(_dragPayload)); } e.dataTransfer.setData('text/plain', node.label); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: hasChildren ? 'pointer' : 'default', borderRadius: 3, background: ds ? (hovered ? `${connColor}28` : `${connColor}10`) : isSelected ? 'var(--selection-bg, rgba(242, 88, 67, 0.12))' : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), borderLeft: ds ? `3px solid ${connColor}` : undefined, outline: isSelected && !ds ? '1px solid var(--primary-color, #F25843)' : undefined, transition: 'background 0.1s', userSelect: 'none', }} > {node.loading ? _Spinner() : chevron} {node.icon} {node.label} {/* Dynamic action: Remove (only when DS exists) — placed LEFT of the * stable trio so the trio always anchors at the right edge. */} {ds && ( )} {/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
{node.expanded && node.children && node.children.length > 0 && (
{node.children.map(child => ( <_TreeNodeView key={child.key} node={child} depth={depth + 1} onToggle={onToggle} onEnsureDs={onEnsureDs} isAdded={isAdded} addingPath={addingPath} dataSources={dataSources} onCycleScope={onCycleScope} onToggleNeutralize={onToggleNeutralize} onRemoveDs={onRemoveDs} onSendToChat={onSendToChat} scopeCycleTitle={scopeCycleTitle} selectedKeys={selectedKeys} onSelect={onSelect} inheritedScope={childInheritedScope} inheritedNeutralize={childInheritedNeutralize} /> ))}
)} {node.expanded && node.children && node.children.length === 0 && !node.loading && (
{t('(leer)')}
)}
); }; /* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ interface _FdsActionProps { featureDataSources: UdbFeatureDataSource[]; onCycleScope: (fds: UdbFeatureDataSource) => void; onToggleNeutralize: (fds: UdbFeatureDataSource) => void; onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; onRemoveFds: (fdsId: string) => void; featureTree: MandateGroupNode[]; } interface _MandateGroupViewProps extends _FdsActionProps { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ group, onToggleGroup, onToggleFeature, onEnsureFds, isTableAdded, addingKey, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, onRemoveFds, featureTree, }) => { 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} onEnsureFds={onEnsureFds} isTableAdded={isTableAdded} addingKey={addingKey} onToggleParentGroup={onToggleParentGroup} onToggleParentRecord={onToggleParentRecord} onAddParentRecord={onAddParentRecord} isParentRecordAdded={isParentRecordAdded} expandedParentGroups={expandedParentGroups} loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} onSendToChat={onSendToChat} featureDataSources={featureDataSources} onCycleScope={onCycleScope} onToggleNeutralize={onToggleNeutralize} onToggleNeutralizeField={onToggleNeutralizeField} onRemoveFds={onRemoveFds} featureTree={featureTree} /> ))}
)}
); }; /* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ interface _FeatureNodeViewProps extends _FdsActionProps { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ node, onToggle, onEnsureFds, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, onRemoveFds, featureTree, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; const wildcardFds = featureDataSources.find( f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, ); const parentTables = (node.tables || []).filter(t => t.isParent); const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable); return (
onToggle(node)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); const payload = JSON.stringify({ featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, objectKey: `data.feature.${node.featureCode}.*`, label: node.label, }); e.dataTransfer.setData('application/feature-source', payload); e.dataTransfer.setData('text/plain', node.label); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: wildcardFds ? (hovered ? '#ede7f6' : '#7b1fa208') : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, transition: 'background 0.1s', userSelect: 'none', }} > {node.loading ? _Spinner() : chevron} {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} {node.label} {node.tableCount} {t('Tabellen')} {/* Dynamic Remove (left of stable trio) */} {wildcardFds && ( )} {/* ── Stable trio: chat | scope | neutralize ── */}
{node.expanded && node.tables && node.tables.length > 0 && (
{/* Parent table groups (hierarchical) */} {parentTables.map(pt => { const groupKey = `${node.featureInstanceId}-${pt.tableName}`; const isGroupExpanded = expandedParentGroups.has(groupKey); const isGroupLoading = loadingParentGroup === groupKey; const records = node.parentRecords[pt.tableName]; const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName); const ptLabel = pt.label || pt.tableName; return ( <_ParentGroupView key={groupKey} featureNode={node} parentTable={pt} label={ptLabel} expanded={isGroupExpanded} loading={isGroupLoading} records={records || null} childTables={childTables} allTables={node.tables!} onToggleGroup={() => onToggleParentGroup(node, pt.tableName)} onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)} onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)} isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)} addingParentKey={addingParentKey} onSendToChat={onSendToChat} featureDataSources={featureDataSources} onCycleScope={onCycleScope} onToggleNeutralize={onToggleNeutralize} onToggleNeutralizeField={onToggleNeutralizeField} onRemoveFds={onRemoveFds} featureTree={featureTree} inheritedScope={wildcardFds?.scope} inheritedNeutralize={wildcardFds?.neutralize} /> ); })} {/* Standalone tables (not part of any hierarchy) */} {standaloneTables.map(table => { const fds = featureDataSources.find( f => f.featureInstanceId === node.featureInstanceId && f.tableName === table.tableName && !f.recordFilter, ); return ( <_FeatureTableRow key={table.objectKey} featureNode={node} table={table} onEnsureFds={onEnsureFds} onSendToChat={onSendToChat} fds={fds} onCycleScope={onCycleScope} onToggleNeutralize={onToggleNeutralize} onToggleNeutralizeField={onToggleNeutralizeField} onRemoveFds={onRemoveFds} featureTree={featureTree} inheritedScope={wildcardFds?.scope} inheritedNeutralize={wildcardFds?.neutralize} /> ); })}
)} {node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
{t('(keine Tabellen)')}
)}
); }; /* ─── FeatureTableRow ────────────────────────────────────────────────── */ interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; fds?: UdbFeatureDataSource; onCycleScope?: (fds: UdbFeatureDataSource) => void; onToggleNeutralize?: (fds: UdbFeatureDataSource) => void; onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; onRemoveFds?: (fdsId: string) => void; featureTree?: MandateGroupNode[]; inheritedScope?: string; inheritedNeutralize?: boolean; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ featureNode, table, onEnsureFds, onSendToChat, fds, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, onRemoveFds, featureTree, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const [fieldsExpanded, setFieldsExpanded] = useState(false); const tableLabel = table.label || table.tableName; const effectiveScope = fds?.scope ?? inheritedScope; const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false; const _chatPayload = { featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, tableName: table.tableName, objectKey: table.objectKey, label: table.label || table.tableName, }; const resolvedFields = featureTree ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) : table.fields; const neutralizedCount = fds?.neutralizeFields?.length ?? 0; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); e.dataTransfer.setData('text/plain', tableLabel); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, borderRadius: 3, background: fds ? (hovered ? '#ede7f6' : '#7b1fa208') : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), transition: 'background 0.1s', userSelect: 'none', }} title={`${table.tableName}: ${table.fields.join(', ')}`} > { e.stopPropagation(); if (resolvedFields.length > 0) setFieldsExpanded(prev => !prev); }} style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0, cursor: resolvedFields.length > 0 ? 'pointer' : 'default' }} > {resolvedFields.length > 0 ? (fieldsExpanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'} {'\uD83D\uDCC1'} {tableLabel} {neutralizedCount > 0 && ( ({neutralizedCount} {t('Felder')}) )} {/* Dynamic Remove (left of stable trio) */} {fds && onRemoveFds && ( )} {/* ── Stable trio: chat | scope | neutralize ── */}
{/* Expandable field sub-nodes */} {fieldsExpanded && resolvedFields.length > 0 && (
{resolvedFields.map(field => { const isNeutralized = (fds?.neutralizeFields || []).includes(field); return ( <_FeatureFieldRow key={field} featureNode={featureNode} table={table} fieldName={field} isNeutralized={isNeutralized || effectiveNeutralize} fds={fds} onToggleNeutralizeField={onToggleNeutralizeField} onSendToChat={onSendToChat} inheritedScope={fds?.scope ?? inheritedScope} /> ); })}
)}
); }; /* ─── FeatureFieldRow (single field under a table) ────────────────────── */ interface _FeatureFieldRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; fieldName: string; isNeutralized: boolean; fds?: UdbFeatureDataSource; onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; inheritedScope?: string; } const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ featureNode, table, fieldName, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const _chatPayload = { featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, tableName: table.tableName, objectKey: table.objectKey, label: `${table.label || table.tableName}.${fieldName}`, fieldName, }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); e.dataTransfer.setData('text/plain', `${table.tableName}.${fieldName}`); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 56, paddingRight: 4, paddingTop: 2, paddingBottom: 2, borderRadius: 3, background: isNeutralized ? (hovered ? '#f3e5f5' : '#f3e5f508') : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), transition: 'background 0.1s', userSelect: 'none', fontSize: 11, }} > {'\u2514'} {fieldName} {/* ── Stable trio: chat | scope | neutralize ── */} {_SCOPE_ICONS[inheritedScope || 'personal']} {fds && onToggleNeutralizeField ? ( ) : ( {'\uD83D\uDD12'} )}
); }; /* ─── ParentGroupView (parent table → parent records) ────────────────── */ interface _ParentGroupViewProps extends _FdsActionProps { featureNode: FeatureConnectionNode; parentTable: FeatureTableNode; label: string; expanded: boolean; loading: boolean; records: ParentRecordNode[] | null; childTables: FeatureTableNode[]; allTables: FeatureTableNode[]; onToggleGroup: () => void; onToggleRecord: (recordId: string) => void; onAddRecord: (record: ParentRecordNode) => void; isRecordAdded: (recordId: string) => boolean; addingParentKey: string | null; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; inheritedScope?: string; inheritedNeutralize?: boolean; } const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({ featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables, onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField: _onToggleNeutralizeField, onRemoveFds, featureTree: _featureTreeRef, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = expanded ? '\u25BE' : '\u25B8'; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 24, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', transition: 'background 0.1s', userSelect: 'none', }} > {loading ? _Spinner() : chevron} {'\uD83D\uDCC2'} {label} {childTables.length > 0 && ( +{childTables.length} {t('Tabellen')} )}
{expanded && records && records.length > 0 && (
{records.map(record => { const recordFds = featureDataSources.find( f => f.featureInstanceId === featureNode.featureInstanceId && f.recordFilter?.id === record.id, ); return ( <_ParentRecordRow key={record.id} featureNode={featureNode} record={record} childTables={childTables} allTables={allTables} onToggle={() => onToggleRecord(record.id)} onAdd={() => onAddRecord(record)} isAdded={isRecordAdded(record.id)} isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`} onSendToChat={onSendToChat} fds={recordFds} onCycleScope={onCycleScope} onToggleNeutralize={onToggleNeutralize} onRemoveFds={onRemoveFds} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} /> ); })}
)} {expanded && records && records.length === 0 && !loading && (
{t('(keine Einträge)')}
)}
); }; /* ─── ParentRecordRow (single parent record + child tables info) ─────── */ interface _ParentRecordRowProps { featureNode: FeatureConnectionNode; record: ParentRecordNode; childTables: FeatureTableNode[]; allTables: FeatureTableNode[]; onToggle: () => void; onAdd: () => void; isAdded: boolean; isAdding: boolean; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; fds?: UdbFeatureDataSource; onCycleScope?: (fds: UdbFeatureDataSource) => void; onToggleNeutralize?: (fds: UdbFeatureDataSource) => void; onRemoveFds?: (fdsId: string) => void; inheritedScope?: string; inheritedNeutralize?: boolean; } const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ featureNode, record, childTables, allTables: _allTables, onToggle, onSendToChat, fds, onCycleScope, onToggleNeutralize, onRemoveFds, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = record.expanded ? '\u25BE' : '\u25B8'; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); const payload = JSON.stringify({ featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, objectKey: `data.feature.${featureNode.featureCode}.${record.tableName || '*'}`, label: record.displayLabel, }); e.dataTransfer.setData('application/feature-source', payload); e.dataTransfer.setData('text/plain', record.displayLabel); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: fds ? (hovered ? '#ede7f6' : '#7b1fa208') : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), transition: 'background 0.1s', userSelect: 'none', }} title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')} > {chevron} {'\uD83D\uDCCB'} {record.displayLabel} {/* Dynamic Remove (left of stable trio) */} {fds && onRemoveFds && ( )} {/* ── Stable trio: chat | scope | neutralize ── */} {fds && onCycleScope ? ( ) : ( {_SCOPE_ICONS[inheritedScope || 'personal']} )} {fds && onToggleNeutralize ? ( ) : ( {'\uD83D\uDD12'} )}
{record.expanded && (
{childTables.map(ct => { const ctLabel = ct.label || ct.tableName; return (
{'\uD83D\uDCC4'} {ctLabel} ({ct.parentKey})
); })}
)}
); }; export default SourcesTab;