/** * 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 (catalog-driven, supports recursive nesting): * MandateGroup * └─ FeatureConnection (feature instance) * ├─ Group (categorical folder, isGroup=true) * │ └─ ParentGroup or Table * ├─ ParentGroup (table with isParent=true) → records * │ └─ Record → child tables (which can themselves be ParentGroups → recursion) * └─ Table (standalone) * * Path-aware state-keys (segments joined by '|', prefixed by featureInstanceId): * g: - categorical group folder * p: - parent group (record list of that table) * r:: - specific record (its child tables rendered when expanded) * * Active Sources sections show scope-cycling and neutralize-toggle buttons. */ import React, { useEffect, useState, useCallback, useRef, useMemo } 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; } interface MandateGroupNode { mandateId: string; mandateLabel: string; expanded: boolean; featureConnections: FeatureConnectionNode[]; } interface FeatureTableNode { objectKey: string; tableName: string; label: string; fields: string[]; isParent?: boolean; parentTable?: string | null; parentKey?: string | null; displayFields?: string[]; isGroup?: boolean; group?: string | null; } interface ParentRecordNode { id: string; displayLabel: string; fields: Record; tableName: string; } /* ─── 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 []; } /* ─── Feature tree builder (catalog → renderable items) ──────────────── */ type FeatureItem = | { kind: 'group'; objectKey: string; label: string; items: FeatureItem[] } | { kind: 'parentGroup'; table: FeatureTableNode } | { kind: 'table'; table: FeatureTableNode }; /** * Build the top-level feature tree from the flat catalog table list. * * - Items with `isGroup: true` become categorical group folders. * - Items with `group: ` are placed inside the corresponding group. * - Items with `parentTable` set are NOT rendered at top level — they are * rendered dynamically when a parent record is expanded. * - Items with `isParent: true` (and no parentTable) become top-level parent groups. * - Everything else renders as a standalone table. * * Catalog declaration order is preserved; group children appear nested under * the group folder in the order they were declared. */ function _buildTopFeatureTree(tables: FeatureTableNode[]): FeatureItem[] { const groupChildren: Record = {}; for (const t of tables) { if (t.isGroup) groupChildren[t.objectKey] = []; } const result: FeatureItem[] = []; for (const t of tables) { if (t.isGroup) { result.push({ kind: 'group', objectKey: t.objectKey, label: t.label, items: groupChildren[t.objectKey] }); } else if (t.parentTable) { // Skip — child tables are rendered when their parent record is expanded. continue; } else if (t.group && groupChildren[t.group]) { const item: FeatureItem = t.isParent ? { kind: 'parentGroup', table: t } : { kind: 'table', table: t }; groupChildren[t.group].push(item); } else if (t.isParent) { result.push({ kind: 'parentGroup', table: t }); } else { result.push({ kind: 'table', table: t }); } } return result; } /** * Children of a parent record: child tables (where parentTable === recordTableName) * rendered as parentGroup or standalone table (recursion enables N-level nesting). */ function _childrenForRecord(allTables: FeatureTableNode[], parentTableName: string): FeatureItem[] { return allTables .filter(t => t.parentTable === parentTableName) .map(t => t.isParent ? { kind: 'parentGroup', table: t } : { kind: 'table', table: t }); } function _pathKey(featureInstanceId: string, segments: string[]): string { return [featureInstanceId, ...segments].join('|'); } /** Walks back through a path to find the closest preceding `r::` segment. */ function _closestRecordSegment(segments: string[]): { tableName: string; recordId: string } | null { for (let i = segments.length - 1; i >= 0; i--) { const seg = segments[i]; if (seg.startsWith('r:')) { const rest = seg.slice(2); const sepIdx = rest.indexOf(':'); if (sepIdx > 0) { return { tableName: rest.slice(0, sepIdx), recordId: rest.slice(sepIdx + 1) }; } } } return null; } /* ─── 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); /* ── Path-aware feature node state (groups, parent groups, records) ── */ const [featureExpandedPaths, setFeatureExpandedPaths] = useState>(new Set()); const [featureRecordsByPath, setFeatureRecordsByPath] = useState>({}); const [featureLoadingPath, setFeatureLoadingPath] = useState(null); const [addingRecordPath, setAddingRecordPath] = 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, onSourcesChanged]); /* ── 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, })), }))); }) .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 ?? [], isGroup: Boolean(t.isGroup), group: t.group ?? null, })); // Default-expand all categorical groups so users immediately see their content. const defaultExpansions: string[] = tables .filter(t => t.isGroup) .map(t => _pathKey(node.featureInstanceId, [`g:${t.objectKey}`])); if (defaultExpansions.length > 0) { setFeatureExpandedPaths(prev => { const next = new Set(prev); for (const k of defaultExpansions) next.add(k); return next; }); } 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, extra?: { recordFilter?: Record; labelOverride?: string }, ): 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: extra?.labelOverride || table.label || table.tableName, ...(extra?.recordFilter ? { recordFilter: extra.recordFilter } : {}), }); _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, onSourcesChanged]); /* ── Feature: check if table already added (no record filter) ── */ const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { return featureDataSources.some(fds => fds.featureInstanceId === featureInstanceId && fds.tableName === tableName && !fds.recordFilter, ); }, [featureDataSources]); /* ── Feature: toggle expand for a path-keyed node (group / parent group / record) ── */ const _toggleFeaturePath = useCallback((pathKey: string) => { setFeatureExpandedPaths(prev => { const next = new Set(prev); if (next.has(pathKey)) next.delete(pathKey); else next.add(pathKey); return next; }); }, []); /** * Load records for a parent group at a given path. * If the path contains a preceding `r:
:` segment, the records are * filtered to children of that ancestor record (nested record hierarchy). */ const _loadRecordsAtPath = useCallback(async ( node: FeatureConnectionNode, table: FeatureTableNode, parentPathSegments: string[], ) => { const segments = [...parentPathSegments, `p:${table.tableName}`]; const pathKey = _pathKey(node.featureInstanceId, segments); if (featureRecordsByPath[pathKey]) return; setFeatureLoadingPath(pathKey); try { const params: Record = {}; const ancestor = _closestRecordSegment(parentPathSegments); if (ancestor && table.parentKey) { params.parentKey = table.parentKey; params.parentValue = ancestor.recordId; } const res = await api.get( `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${table.tableName}`, Object.keys(params).length > 0 ? { params } : undefined, ); const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({ id: r.id, displayLabel: r.displayLabel || r.id, fields: r.fields || {}, tableName: table.tableName, })); if (mountedRef.current) { setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records })); } } catch { if (mountedRef.current) { setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: [] })); } } finally { if (mountedRef.current) setFeatureLoadingPath(null); } }, [instanceId, featureRecordsByPath]); /** * Add a parent record + all its DIRECT child tables as FeatureDataSources. * * - Parent itself: recordFilter = { id: } * - Each direct child table: recordFilter = { : } * * Nested parent groups (e.g. CoachingSession under CoachingContext) are added * with the FK-only filter, scoping to "all sub-records of this ancestor". * Users can drill in further to add a specific sub-record. */ const _addRecordWithChildren = useCallback(async ( node: FeatureConnectionNode, parentTable: FeatureTableNode, record: ParentRecordNode, pathSegments: string[], ) => { const addKey = `${_pathKey(node.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; setAddingRecordPath(addKey); try { const allTables = node.tables || []; const parentLabel = `${parentTable.label || parentTable.tableName}: ${record.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: record.id }, }); const childTables = allTables.filter(t => t.parentTable === parentTable.tableName); for (const child of childTables) { if (!child.parentKey) continue; const childLabel = `${child.label || child.tableName}: ${record.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]: record.id }, }); } _fetchFeatureDataSources(); onSourcesChanged?.(); } catch (err) { console.error('Failed to add record sources:', err); } finally { if (mountedRef.current) setAddingRecordPath(null); } }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); /* ── Check if a parent record is already added ── */ const _isRecordAdded = 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} 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} onAddFeatureTable={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} featureExpandedPaths={featureExpandedPaths} featureRecordsByPath={featureRecordsByPath} featureLoadingPath={featureLoadingPath} addingRecordPath={addingRecordPath} onToggleFeaturePath={_toggleFeaturePath} onLoadRecordsAtPath={_loadRecordsAtPath} onAddRecordWithChildren={_addRecordWithChildren} isRecordAdded={_isRecordAdded} onSendToChat={onSendToChat_FeatureSource} featureDataSources={featureDataSources} onCycleScope={_cycleFeatureScope} onToggleNeutralize={_toggleFeatureNeutralize} onToggleNeutralizeField={_toggleNeutralizeField} featureTree={featureTree} /> ))}
); }; /* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */ 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; 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, 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, // Compensate the 3px borderLeft on active rows with -3px paddingLeft so // the row content stays at exactly the same x-position as inactive rows. paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0), 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} {/* ── Stable trio: chat | scope | neutralize (always in this order). * No "remove from workspace" button here by design: the UDB row only * exposes the catalog state. Detach from the *current chat* happens * via the chip "x" in WorkspaceInput; that chip is the single source * of truth for chat-scoped attachment lifecycle. */}
{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} onSendToChat={onSendToChat} scopeCycleTitle={scopeCycleTitle} selectedKeys={selectedKeys} onSelect={onSelect} inheritedScope={childInheritedScope} inheritedNeutralize={childInheritedNeutralize} /> ))}
)} {node.expanded && node.children && node.children.length === 0 && !node.loading && (
{t('(leer)')}
)}
); }; /* ─── Feature-side action props (shared) ─────────────────────────────── */ interface _FeatureActionContext { featureExpandedPaths: Set; featureRecordsByPath: Record; featureLoadingPath: string | null; addingRecordPath: string | null; onToggleFeaturePath: (pathKey: string) => void; onLoadRecordsAtPath: ( node: FeatureConnectionNode, table: FeatureTableNode, parentPathSegments: string[], ) => void; onAddRecordWithChildren: ( node: FeatureConnectionNode, parentTable: FeatureTableNode, record: ParentRecordNode, pathSegments: string[], ) => void; isRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; onAddFeatureTable: ( node: FeatureConnectionNode, table: FeatureTableNode, extra?: { recordFilter?: Record; labelOverride?: string }, ) => Promise; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; featureDataSources: UdbFeatureDataSource[]; onCycleScope: (fds: UdbFeatureDataSource) => void; onToggleNeutralize: (fds: UdbFeatureDataSource) => void; onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; featureTree: MandateGroupNode[]; } /* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ interface _MandateGroupViewProps extends _FeatureActionContext { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; } const _MandateGroupView: React.FC<_MandateGroupViewProps> = (props) => { const { group, onToggleGroup, onToggleFeature, ...ctx } = props; 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} {...ctx} /> ))}
)}
); }; /* ─── FeatureNodeView (feature instance + recursive items) ───────────── */ interface _FeatureNodeViewProps extends _FeatureActionContext { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; } const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => { const { node, onToggle, ...ctx } = props; const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; const wildcardFds = ctx.featureDataSources.find( f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, ); const topItems = useMemo( () => _buildTopFeatureTree(node.tables || []), [node.tables], ); 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, // Compensate the 3px borderLeft on active wildcard rows with -3px // paddingLeft so the row content stays at the same x-position. paddingLeft: wildcardFds ? 1 : 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')}
{node.expanded && topItems.length > 0 && (
{topItems.map((item, idx) => ( <_FeatureItemView key={_itemKey(item, idx)} featureNode={node} item={item} pathSegments={[]} depth={1} inheritedScope={wildcardFds?.scope} inheritedNeutralize={wildcardFds?.neutralize} {...ctx} /> ))}
)} {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
{t('(keine Tabellen)')}
)}
); }; function _itemKey(item: FeatureItem, idx: number): string { if (item.kind === 'group') return `g:${item.objectKey}-${idx}`; if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`; return `t:${item.table.tableName}-${idx}`; } /* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */ interface _FeatureItemViewProps extends _FeatureActionContext { featureNode: FeatureConnectionNode; item: FeatureItem; pathSegments: string[]; depth: number; inheritedScope?: string; inheritedNeutralize?: boolean; } const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => { const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; if (item.kind === 'group') { return ( <_GroupFolderView featureNode={featureNode} objectKey={item.objectKey} label={item.label} items={item.items} pathSegments={pathSegments} depth={depth} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} {...ctx} /> ); } if (item.kind === 'parentGroup') { return ( <_ParentGroupView featureNode={featureNode} table={item.table} pathSegments={pathSegments} depth={depth} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} {...ctx} /> ); } return ( <_FeatureTableRow featureNode={featureNode} table={item.table} depth={depth} onAddFeatureTable={ctx.onAddFeatureTable} onSendToChat={ctx.onSendToChat} featureDataSources={ctx.featureDataSources} onCycleScope={ctx.onCycleScope} onToggleNeutralize={ctx.onToggleNeutralize} onToggleNeutralizeField={ctx.onToggleNeutralizeField} featureTree={ctx.featureTree} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} /> ); }; /* ─── GroupFolderView (categorical folder) ───────────────────────────── */ interface _GroupFolderViewProps extends _FeatureActionContext { featureNode: FeatureConnectionNode; objectKey: string; label: string; items: FeatureItem[]; pathSegments: string[]; depth: number; inheritedScope?: string; inheritedNeutralize?: boolean; } const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const segments = [...pathSegments, `g:${objectKey}`]; const pathKey = _pathKey(featureNode.featureInstanceId, segments); const expanded = ctx.featureExpandedPaths.has(pathKey); const chevron = expanded ? '\u25BE' : '\u25B8'; // Container-wildcard objectKey: matches every record/table inside this group. // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*` // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize // for every child without having to add each one individually. const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`; const wildcardFds = ctx.featureDataSources.find( f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey, ); const _chatPayload = { featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, objectKey: containerObjectKey, label, }; return (
ctx.onToggleFeaturePath(pathKey)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); e.dataTransfer.setData('text/plain', label); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, // Compensate the 3px border on active wildcard rows so the row // content stays at the same x-position whether or not it's active. paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), 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', }} > {chevron} {'\uD83D\uDCC1'} {label}
{expanded && items.length > 0 && (
{items.map((sub, idx) => ( <_FeatureItemView key={_itemKey(sub, idx)} featureNode={featureNode} item={sub} pathSegments={segments} depth={depth + 1} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} {...ctx} /> ))}
)}
); }; /* ─── ParentGroupView (parent table → list of records) ───────────────── */ interface _ParentGroupViewProps extends _FeatureActionContext { featureNode: FeatureConnectionNode; table: FeatureTableNode; pathSegments: string[]; depth: number; inheritedScope?: string; inheritedNeutralize?: boolean; } const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const segments = [...pathSegments, `p:${table.tableName}`]; const pathKey = _pathKey(featureNode.featureInstanceId, segments); const expanded = ctx.featureExpandedPaths.has(pathKey); const loading = ctx.featureLoadingPath === pathKey; const records = ctx.featureRecordsByPath[pathKey]; const chevron = expanded ? '\u25BE' : '\u25B8'; const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName); const _onToggle = () => { const willExpand = !expanded; ctx.onToggleFeaturePath(pathKey); if (willExpand && !records) { ctx.onLoadRecordsAtPath(featureNode, table, pathSegments); } }; // Container-wildcard objectKey for the parent group: matches every record in // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list. const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`; const wildcardFds = ctx.featureDataSources.find( f => f.featureInstanceId === featureNode.featureInstanceId && f.tableName === table.tableName && !f.recordFilter && f.objectKey === containerObjectKey, ); const _chatPayload = { featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, tableName: table.tableName, objectKey: containerObjectKey, label: table.label || table.tableName, }; 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', _chatPayload.label); e.dataTransfer.effectAllowed = 'copy'; }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), 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', }} > {loading ? _Spinner() : chevron} {'\uD83D\uDCC2'} {table.label || table.tableName} {childTables.length > 0 && ( +{childTables.length} {t('Tabellen')} )}
{expanded && records && records.length > 0 && (
{records.map(record => ( <_RecordRowView key={record.id} featureNode={featureNode} parentTable={table} record={record} pathSegments={segments} depth={depth + 1} inheritedScope={inheritedScope} inheritedNeutralize={inheritedNeutralize} {...ctx} /> ))}
)} {expanded && records && records.length === 0 && !loading && (
{t('(keine Einträge)')}
)}
); }; /* ─── RecordRowView (single record + recursive children when expanded) ── */ interface _RecordRowViewProps extends _FeatureActionContext { featureNode: FeatureConnectionNode; parentTable: FeatureTableNode; record: ParentRecordNode; pathSegments: string[]; depth: number; inheritedScope?: string; inheritedNeutralize?: boolean; } const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => { const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`]; const pathKey = _pathKey(featureNode.featureInstanceId, segments); const expanded = ctx.featureExpandedPaths.has(pathKey); const chevron = expanded ? '\u25BE' : '\u25B8'; const fds = ctx.featureDataSources.find( f => f.featureInstanceId === featureNode.featureInstanceId && f.tableName === parentTable.tableName && f.recordFilter?.id === record.id, ); const childItems = useMemo( () => _childrenForRecord(featureNode.tables || [], parentTable.tableName), [featureNode.tables, parentTable.tableName], ); const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id); const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; const isAdding = ctx.addingRecordPath === addingKey; const _chatPayload = { featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, tableName: parentTable.tableName, objectKey: parentTable.objectKey, label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`, }; return (
ctx.onToggleFeaturePath(pathKey)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} draggable onDragStart={(e) => { e.stopPropagation(); const payload = JSON.stringify({ featureInstanceId: featureNode.featureInstanceId, featureCode: featureNode.featureCode, objectKey: parentTable.objectKey, label: `${parentTable.label || parentTable.tableName}: ${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: depth * 16 + 4, 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} {/* Add record + direct children as data sources (only when not already added). */} {!fds && !isAdded && ( )} {fds ? ( ) : ( {_SCOPE_ICONS[inheritedScope || 'personal']} )} {fds ? ( ) : ( {'\uD83D\uDD12'} )}
{expanded && childItems.length > 0 && (
{childItems.map((sub, idx) => ( <_FeatureItemView key={_itemKey(sub, idx)} featureNode={featureNode} item={sub} pathSegments={segments} depth={depth + 1} inheritedScope={fds?.scope ?? inheritedScope} inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize} {...ctx} /> ))}
)}
); }; /* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */ interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; depth: number; onAddFeatureTable: ( node: FeatureConnectionNode, table: FeatureTableNode, extra?: { recordFilter?: Record; labelOverride?: string }, ) => Promise; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; featureDataSources: UdbFeatureDataSource[]; onCycleScope: (fds: UdbFeatureDataSource) => void; onToggleNeutralize: (fds: UdbFeatureDataSource) => void; onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; featureTree: MandateGroupNode[]; inheritedScope?: string; inheritedNeutralize?: boolean; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ featureNode, table, depth, onAddFeatureTable, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, featureTree, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const [fieldsExpanded, setFieldsExpanded] = useState(false); const tableLabel = table.label || table.tableName; const fds = featureDataSources.find( f => f.featureInstanceId === featureNode.featureInstanceId && f.tableName === table.tableName && !f.recordFilter, ); 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: tableLabel, }; 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: depth * 16 + 4, 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')}) )}
{fieldsExpanded && resolvedFields.length > 0 && (
{resolvedFields.map(field => { const isNeutralized = (fds?.neutralizeFields || []).includes(field); return ( <_FeatureFieldRow key={field} featureNode={featureNode} table={table} fieldName={field} depth={depth + 1} 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; depth: number; 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, depth, 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: depth * 16 + 8, 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} {_SCOPE_ICONS[inheritedScope || 'personal']} {fds && onToggleNeutralizeField ? ( ) : ( {'\uD83D\uDD12'} )}
); }; export default SourcesTab;