frontend_nyla/src/components/UnifiedDataBar/SourcesTab.tsx
2026-04-19 01:22:34 +02:00

2024 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, string>;
}
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<string, ParentRecordNode[]>;
}
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<string, any>;
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<string, string> = {
msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB',
'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27',
};
const _SERVICE_ICONS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<TreeNode[]> {
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<TreeNode[]> {
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<string, string> = {
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 (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid var(--border-color, #ccc)', borderTopColor: 'var(--primary-color, #F25843)',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Component ──────────────────────────────────────────────────────── */
const SourcesTab: React.FC<SourcesTabProps> = ({ 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<string, string>)[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<UdbDataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);
/* ── Browse tree state ── */
const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null);
/* ── Feature tree state ── */
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
/* ── Multi-selection state for Browse-Tree ── */
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const lastClickedKeyRef = useRef<string | null>(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<string | null> => {
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<string | null> => {
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<Set<string>>(new Set());
const [loadingParentGroup, setLoadingParentGroup] = useState<string | null>(null);
const [addingParentKey, setAddingParentKey] = useState<string | null>(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 (
<div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
{/* ── Browse Sources header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
{t('Quellen durchsuchen')}
</span>
<button
onClick={_loadConnections}
disabled={loadingRoot}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--primary-color, #F25843)' }}
>
{loadingRoot ? '...' : '\u21BB'}
</button>
</div>
{/* ── Browse Sources tree ── */}
{loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Verbindungen werden geladen…')}
</div>
)}
{!loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Keine aktiven Verbindungen.')}
</div>
)}
{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 ── */}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
{/* ── Feature Data header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
{t('Feature-Daten')}
</span>
<button
onClick={_loadFeatureConnections}
disabled={loadingFeatures}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#7b1fa2' }}
>
{loadingFeatures ? '...' : '\u21BB'}
</button>
</div>
{/* ── Feature Data tree ── */}
{loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Feature-Instanzen werden geladen…')}
</div>
)}
{!loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{t('Keine Feature-Instanzen gefunden.')}
</div>
)}
{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}
/>
))}
</div>
);
};
/* ─── 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<string | null>;
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<string>;
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 (
<div>
<div
onClick={(e) => {
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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{node.icon}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12,
fontWeight: (node.type === 'connection' || ds) ? 600 : 400,
}}>
{node.label}
</span>
{/* Dynamic action: Remove (only when DS exists) — placed LEFT of the
* stable trio so the trio always anchors at the right edge. */}
{ds && (
<button
onClick={e => { e.stopPropagation(); onRemoveDs(ds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize (always in this order) ── */}
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: ds ? 0.7 : (hovered ? 0.5 : 0.25),
color: 'var(--primary-color, #F25843)',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onCycleScope(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleNeutralize(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}}
title={(ds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
</div>
{node.expanded && node.children && node.children.length > 0 && (
<div>
{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}
/>
))}
</div>
)}
{node.expanded && node.children && node.children.length === 0 && !node.loading && (
<div style={{ paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb', padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px' }}>
{t('(leer)')}
</div>
)}
</div>
);
};
/* ─── 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<string | null>;
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<string>;
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 (
<div>
<div
onClick={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 700, color: '#555' }}>
{group.mandateLabel}
</span>
</div>
{group.expanded && (
<div style={{ paddingLeft: 10 }}>
{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}
/>
))}
</div>
)}
</div>
);
};
/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */
interface _FeatureNodeViewProps extends _FdsActionProps {
node: FeatureConnectionNode;
onToggle: (node: FeatureConnectionNode) => void;
onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise<string | null>;
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<string>;
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 (
<div>
<div
onClick={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{node.loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600 }}>
{node.label}
</span>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
{node.tableCount} {t('Tabellen')}
</span>
{/* Dynamic Remove (left of stable trio) */}
{wildcardFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(wildcardFds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={(e) => {
e.stopPropagation();
onSendToChat?.({
featureInstanceId: node.featureInstanceId,
featureCode: node.featureCode,
objectKey: `data.feature.${node.featureCode}.*`,
label: node.label,
});
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: wildcardFds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('Alle Tabellen in Chat senden')}
>
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { onCycleScope(wildcardFds); return; }
const newId = await onEnsureFds(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
>
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { onToggleNeutralize(wildcardFds); return; }
const newId = await onEnsureFds(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? (wildcardFds.neutralize ? 1 : 0.35) : 0.35 }}
title={wildcardFds?.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
</div>
{node.expanded && node.tables && node.tables.length > 0 && (
<div>
{/* 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}
/>
);
})}
</div>
)}
{node.expanded && node.tables && node.tables.length === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
{t('(keine Tabellen)')}
</div>
)}
</div>
);
};
/* ─── FeatureTableRow ────────────────────────────────────────────────── */
interface _FeatureTableRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise<string | null>;
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 (
<div>
<div
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', 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(', ')}`}
>
<span
onClick={e => { 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'}
</span>
<span style={{ fontSize: 14, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12, fontWeight: fds ? 600 : 400,
}}>
{tableLabel}
{neutralizedCount > 0 && (
<span style={{ fontSize: 9, color: '#7b1fa2', marginLeft: 4 }}>({neutralizedCount} {t('Felder')})</span>
)}
</span>
{/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={e => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (fds && onCycleScope) { onCycleScope(fds); return; }
const newId = await onEnsureFds(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (fds && onToggleNeutralize) { onToggleNeutralize(fds); return; }
const newId = await onEnsureFds(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
}}
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
</div>
{/* Expandable field sub-nodes */}
{fieldsExpanded && resolvedFields.length > 0 && (
<div>
{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}
/>
);
})}
</div>
)}
</div>
);
};
/* ─── 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 (
<div
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', `${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,
}}
>
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>{'\u2514'}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontWeight: isNeutralized ? 600 : 400,
color: isNeutralized ? '#7b1fa2' : undefined,
}}>
{fieldName}
</span>
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('Feld in Chat senden')}
>
{'\u{1F4AC}'}
</button>
<span
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
{fds && onToggleNeutralizeField ? (
<button
onClick={e => { e.stopPropagation(); onToggleNeutralizeField(fds, fieldName); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: isNeutralized ? 1 : 0.35,
}}
title={isNeutralized ? t('Feld-Neutralisierung an') : t('Feld-Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
) : (
<span
style={{ fontSize: 11, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: isNeutralized ? 0.5 : 0.15, display: 'inline-block' }}
title={isNeutralized ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</span>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={onToggleGroup}
onMouseEnter={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{loading ? _Spinner() : chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCC2'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontWeight: 600, color: '#555' }}>
{label}
</span>
{childTables.length > 0 && (
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
+{childTables.length} {t('Tabellen')}
</span>
)}
</div>
{expanded && records && records.length > 0 && (
<div>
{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}
/>
);
})}
</div>
)}
{expanded && records && records.length === 0 && !loading && (
<div style={{ paddingLeft: 52, fontSize: 11, color: '#bbb', padding: '2px 0 2px 52px' }}>
{t('(keine Einträge)')}
</div>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={onToggle}
onMouseEnter={() => 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(', ')}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCCB'}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12, fontWeight: fds ? 600 : 400,
}}>
{record.displayLabel}
</span>
{/* Dynamic Remove (left of stable trio) */}
{fds && onRemoveFds && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveFds(fds.id); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px', flexShrink: 0 }}
title={t('Entfernen')}
>
{'\u2715'}
</button>
)}
{/* ── Stable trio: chat | scope | neutralize ── */}
<button
onClick={(e) => {
e.stopPropagation();
onSendToChat?.({
featureInstanceId: featureNode.featureInstanceId,
featureCode: featureNode.featureCode,
objectKey: `data.feature.${featureNode.featureCode}.${record.tableName || '*'}`,
label: record.displayLabel,
});
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', flexShrink: 0, lineHeight: 1, width: 22, textAlign: 'center',
opacity: fds ? 0.7 : (hovered ? 0.5 : 0.25), color: '#7b1fa2',
}}
title={t('In Chat senden')}
>
{'\u{1F4AC}'}
</button>
{fds && onCycleScope ? (
<button
onClick={(e) => { e.stopPropagation(); onCycleScope(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
title={`${t('Bereich')}: ${fds.scope}`}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: 0.25, display: 'inline-block' }}
title={inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Kein Scope gesetzt')}
>
{_SCOPE_ICONS[inheritedScope || 'personal']}
</span>
)}
{fds && onToggleNeutralize ? (
<button
onClick={(e) => { e.stopPropagation(); onToggleNeutralize(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
) : (
<span
style={{ fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (inheritedNeutralize ?? false) ? 0.5 : 0.15, display: 'inline-block' }}
title={(inheritedNeutralize ?? false) ? t('Neutralisierung geerbt') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</span>
)}
</div>
{record.expanded && (
<div style={{ paddingLeft: 64 }}>
{childTables.map(ct => {
const ctLabel = ct.label || ct.tableName;
return (
<div key={ct.objectKey} style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingTop: 2, paddingBottom: 2, fontSize: 11, color: '#888',
}}>
<span style={{ fontSize: 12 }}>{'\uD83D\uDCC4'}</span>
<span>{ctLabel}</span>
<span style={{ fontSize: 10, color: '#bbb' }}>({ct.parentKey})</span>
</div>
);
})}
</div>
)}
</div>
);
};
export default SourcesTab;