2336 lines
89 KiB
TypeScript
2336 lines
89 KiB
TypeScript
/**
|
||
* 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',
|
||
infomaniak: '\uD83D\uDFE5',
|
||
'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',
|
||
clickup: '\uD83D\uDCCB',
|
||
kdrive: '\uD83D\uDCC2',
|
||
mail: '\uD83D\uDCE7',
|
||
};
|
||
|
||
/* ─── 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',
|
||
kdriveFolder: '#0098FF',
|
||
kdrive: '#0098FF',
|
||
mailFolder: '#0098FF',
|
||
mail: '#0098FF',
|
||
};
|
||
|
||
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',
|
||
clickup: 'clickup',
|
||
kdrive: 'kdriveFolder',
|
||
mail: 'mailFolder',
|
||
};
|
||
|
||
/* ─── 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;
|