frontend_nyla/src/components/UnifiedDataBar/SourcesTab.tsx
2026-04-21 23:49:50 +02:00

2325 lines
89 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 (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:<objectKey> - categorical group folder
* p:<tableName> - parent group (record list of that table)
* r:<tableName>:<id> - 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<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;
}
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<string, any>;
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<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 [];
}
/* ─── 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: <objectKey>` 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<string, FeatureItem[]> = {};
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<FeatureItem>(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:<table>:<id>` 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<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);
/* ── Path-aware feature node state (groups, parent groups, records) ── */
const [featureExpandedPaths, setFeatureExpandedPaths] = useState<Set<string>>(new Set());
const [featureRecordsByPath, setFeatureRecordsByPath] = useState<Record<string, ParentRecordNode[]>>({});
const [featureLoadingPath, setFeatureLoadingPath] = useState<string | null>(null);
const [addingRecordPath, setAddingRecordPath] = 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, 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<string, string>; labelOverride?: string },
): 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: 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:<table>:<id>` 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<string, string> = {};
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: <recordId> }
* - Each direct child table: recordFilter = { <child.parentKey>: <recordId> }
*
* 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 (
<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}
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}
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}
/>
))}
</div>
);
};
/* ─── 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<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;
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, 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,
// 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',
}}
>
<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>
{/* ── 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. */}
<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}
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>
);
};
/* ─── Feature-side action props (shared) ─────────────────────────────── */
interface _FeatureActionContext {
featureExpandedPaths: Set<string>;
featureRecordsByPath: Record<string, ParentRecordNode[]>;
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<string, string>; labelOverride?: string },
) => Promise<string | null>;
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 (
<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}
{...ctx}
/>
))}
</div>
)}
</div>
);
};
/* ─── 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 (
<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,
// 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',
}}
>
<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>
<button
onClick={(e) => {
e.stopPropagation();
ctx.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) { ctx.onCycleScope(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(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) { ctx.onToggleNeutralize(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(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 && topItems.length > 0 && (
<div>
{topItems.map((item, idx) => (
<_FeatureItemView
key={_itemKey(item, idx)}
featureNode={node}
item={item}
pathSegments={[]}
depth={1}
inheritedScope={wildcardFds?.scope}
inheritedNeutralize={wildcardFds?.neutralize}
{...ctx}
/>
))}
</div>
)}
{node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
<div style={{ paddingLeft: 36, fontSize: 11, color: '#bbb', padding: '2px 0 2px 36px' }}>
{t('(keine Tabellen)')}
</div>
)}
</div>
);
};
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 (
<div>
<div
onClick={() => 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',
}}
>
<span style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0 }}>
{chevron}
</span>
<span style={{ fontSize: 13, flexShrink: 0 }}>{'\uD83D\uDCC1'}</span>
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 12, fontWeight: 600, color: '#555',
textTransform: 'uppercase', letterSpacing: 0.3,
}}>
{label}
</span>
<button
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
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('Container in Chat senden')}
>
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || '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}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ objectKey: containerObjectKey, tableName: '*', 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?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
</div>
{expanded && items.length > 0 && (
<div>
{items.map((sub, idx) => (
<_FeatureItemView
key={_itemKey(sub, idx)}
featureNode={featureNode}
item={sub}
pathSegments={segments}
depth={depth + 1}
inheritedScope={inheritedScope}
inheritedNeutralize={inheritedNeutralize}
{...ctx}
/>
))}
</div>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={_onToggle}
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', _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',
}}
>
<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',
}}>
{table.label || table.tableName}
</span>
{childTables.length > 0 && (
<span style={{ fontSize: 10, color: '#999', flexShrink: 0 }}>
+{childTables.length} {t('Tabellen')}
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); ctx.onSendToChat?.(_chatPayload); }}
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('Container in Chat senden')}
>
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
{ labelOverride: _chatPayload.label },
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || '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}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
{ labelOverride: _chatPayload.label },
);
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?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
title={(wildcardFds?.neutralize ?? inheritedNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
</div>
{expanded && records && records.length > 0 && (
<div>
{records.map(record => (
<_RecordRowView
key={record.id}
featureNode={featureNode}
parentTable={table}
record={record}
pathSegments={segments}
depth={depth + 1}
inheritedScope={inheritedScope}
inheritedNeutralize={inheritedNeutralize}
{...ctx}
/>
))}
</div>
)}
{expanded && records && records.length === 0 && !loading && (
<div style={{
paddingLeft: (depth + 1) * 16 + 20, fontSize: 11, color: '#bbb',
padding: '2px 0 2px ' + ((depth + 1) * 16 + 20) + 'px',
}}>
{t('(keine Einträge)')}
</div>
)}
</div>
);
};
/* ─── 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 (
<div>
<div
onClick={() => 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(', ')}
>
<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>
{/* Add record + direct children as data sources (only when not already added). */}
{!fds && !isAdded && (
<button
onClick={(e) => {
e.stopPropagation();
ctx.onAddRecordWithChildren(featureNode, parentTable, record, pathSegments);
}}
disabled={isAdding}
style={{
background: 'none', border: 'none', cursor: isAdding ? 'wait' : 'pointer',
fontSize: 12, color: '#7b1fa2', padding: '0 2px', flexShrink: 0,
opacity: hovered ? 1 : 0.5,
}}
title={t('Datensatz + Kind-Tabellen als Quelle hinzufügen')}
>
{isAdding ? '\u23F3' : '+'}
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); ctx.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>
{fds ? (
<button
onClick={(e) => { e.stopPropagation(); ctx.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 ? (
<button
onClick={(e) => { e.stopPropagation(); ctx.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>
{expanded && childItems.length > 0 && (
<div>
{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}
/>
))}
</div>
)}
</div>
);
};
/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */
interface _FeatureTableRowProps {
featureNode: FeatureConnectionNode;
table: FeatureTableNode;
depth: number;
onAddFeatureTable: (
node: FeatureConnectionNode,
table: FeatureTableNode,
extra?: { recordFilter?: Record<string, string>; labelOverride?: string },
) => Promise<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[];
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 (
<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: 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(', ')}`}
>
<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>
<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(fds); return; }
const newId = await onAddFeatureTable(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(fds); return; }
const newId = await onAddFeatureTable(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>
{fieldsExpanded && resolvedFields.length > 0 && (
<div>
{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}
/>
);
})}
</div>
)}
</div>
);
};
/* ─── 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 (
<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: 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,
}}
>
<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>
<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>
);
};
export default SourcesTab;