diff --git a/src/App.tsx b/src/App.tsx index 983bec1..c6fecb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR'; import StorePage from './pages/Store'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { FeatureViewPage } from './pages/FeatureView'; -import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin'; +import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; @@ -213,6 +213,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 347ad0b..a321c40 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -60,6 +60,8 @@ export interface NodeType { meta?: { icon?: string; color?: string; + /** True if this node performs an LLM / AI call (credits). */ + usesAi?: boolean; method?: string; action?: string; }; diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index d52166b..b2f5605 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -152,8 +152,18 @@ min-width: 0; } +.nodeItemLabelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + width: 100%; +} + .nodeItemLabel { display: block; + flex: 1; + min-width: 0; font-size: 0.875rem; font-weight: 500; color: var(--text-primary, #333); diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index 3589a2d..ae236ea 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -8,6 +8,7 @@ import type { NodeType } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { AiBadge } from '../nodes/shared/AiBadge'; export interface CanvasNode { id: string; @@ -798,6 +799,12 @@ export const FlowCanvas: React.FC = ({ nodes, handleNodeMouseDown(e, node.id); }} > + {nt?.meta?.usesAi === true && ( + + )} {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); diff --git a/src/components/FlowEditor/editor/NodeListItem.tsx b/src/components/FlowEditor/editor/NodeListItem.tsx index 165f421..43b0c03 100644 --- a/src/components/FlowEditor/editor/NodeListItem.tsx +++ b/src/components/FlowEditor/editor/NodeListItem.tsx @@ -5,9 +5,11 @@ import React from 'react'; import type { NodeType } from '../../../api/workflowApi'; +import { useLanguage } from '../../../providers/language/LanguageContext'; import { getCategoryIcon } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils'; import styles from './Automation2FlowEditor.module.css'; +import { AiBadge } from '../nodes/shared/AiBadge'; interface NodeListItemProps { node: NodeType; @@ -22,6 +24,7 @@ export const NodeListItem: React.FC = ({ getLabel, getCategoryIcon: getIcon = getCategoryIcon, }) => { + const { t } = useLanguage(); const desc = getLabel(node.description, language); return (
= ({ {getIcon(node.category)}
- {getLabel(node.label, language)} + + {getLabel(node.label, language)} + {node.meta?.usesAi === true && ( + + )} + {desc}
{desc &&
{desc}
} diff --git a/src/components/FlowEditor/nodes/shared/AiBadge.module.css b/src/components/FlowEditor/nodes/shared/AiBadge.module.css new file mode 100644 index 0000000..233ff03 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/AiBadge.module.css @@ -0,0 +1,24 @@ +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.12rem 0.38rem; + border-radius: 4px; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: linear-gradient(135deg, #7c4dff 0%, #9c27b0 100%); + color: #fff; + line-height: 1; + flex-shrink: 0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.badgeCanvas { + position: absolute; + top: 4px; + right: 6px; + z-index: 3; + pointer-events: auto; +} diff --git a/src/components/FlowEditor/nodes/shared/AiBadge.tsx b/src/components/FlowEditor/nodes/shared/AiBadge.tsx new file mode 100644 index 0000000..70b4486 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/AiBadge.tsx @@ -0,0 +1,25 @@ +/** + * Small label for workflow nodes that consume AI credits (LLM calls). + */ + +import React from 'react'; +import badgeStyles from './AiBadge.module.css'; + +export interface AiBadgeProps { + /** Tooltip (e.g. cost / credits hint). */ + title: string; + /** Canvas nodes: fixed top-right on the node card. */ + variant?: 'canvas' | 'palette'; +} + +export const AiBadge: React.FC = ({ title, variant = 'palette' }) => { + const cls = + variant === 'canvas' + ? `${badgeStyles.badge} ${badgeStyles.badgeCanvas}` + : badgeStyles.badge; + return ( + + AI + + ); +}; diff --git a/src/components/FlowEditor/nodes/shared/constants.ts b/src/components/FlowEditor/nodes/shared/constants.ts index b323fec..91d7359 100644 --- a/src/components/FlowEditor/nodes/shared/constants.ts +++ b/src/components/FlowEditor/nodes/shared/constants.ts @@ -12,9 +12,11 @@ export const CATEGORY_ORDER = [ 'input', 'flow', 'data', + 'context', 'ai', 'file', 'email', 'sharepoint', 'clickup', + 'trustee', ] as const; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 15a4d79..2e2194c 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -29,6 +29,7 @@ export interface FolderNode { isProtected?: boolean; isReadonly?: boolean; icon?: string; + neutralize?: boolean; } export interface FileNode { @@ -75,6 +76,8 @@ export interface FolderTreeProps { onDownloadFolder?: (folderId: string, folderName: string) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; + onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -180,6 +183,7 @@ interface SelectionCtx { onDeleteFolders?: (folderIds: string[]) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ @@ -262,6 +266,11 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { {!renaming && ( + {sel.onSendToChat && ( + + )} {sel.onRenameFile && !multiSelected && ( + )} {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( )} + {onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - - - ); - })} -
-
- )} - {/* ── Browse Sources header ── */}
@@ -905,152 +897,23 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) => node={node} depth={0} onToggle={_toggleNode} - onAdd={_addAsDataSource} + onEnsureDs={_addAsDataSource} isAdded={_isAdded} addingPath={addingPath} + dataSources={dataSources} + onCycleScope={_cyclePersonalScope} + onToggleNeutralize={_togglePersonalNeutralize} + onRemoveDs={_removeDatasource} + onSendToChat={_sendNodeToChat} + scopeCycleTitle={_scopeCycleTitle} + selectedKeys={selectedKeys} + onSelect={_handleNodeSelect} /> ))} {/* ── Divider ── */}
- {/* ── Active Feature Sources (grouped by parent record) ── */} - {featureDataSources.length > 0 && ( -
-
- {t('Aktive Feature-Quellen')} -
- {(() => { - const sorted = [...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || '')); - const grouped: { key: string; label: string; items: UdbFeatureDataSource[] }[] = []; - const standalone: UdbFeatureDataSource[] = []; - - for (const fds of sorted) { - if (fds.recordFilter && Object.keys(fds.recordFilter).length > 0) { - const filterKey = `${fds.featureInstanceId}|${JSON.stringify(fds.recordFilter)}`; - let group = grouped.find(g => g.key === filterKey); - if (!group) { - const parentLabel = fds.label.includes(':') ? fds.label.split(':')[1]?.trim() : fds.label; - const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); - group = { key: filterKey, label: `${meta?.instanceLabel || fds.featureCode} – ${parentLabel}`, items: [] }; - grouped.push(group); - } - group.items.push(fds); - } else { - standalone.push(fds); - } - } - - return ( - <> - {grouped.map(group => ( -
-
- {'\uD83D\uDCCB'} - - {group.label} - - -
- {group.items.map(fds => { - const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); - return ( -
- - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDCC4'} - - - {fds.tableName} - - - - -
- ); - })} -
- ))} - {standalone.map(fds => { - const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); - const fdsConnLabel = meta?.instanceLabel || fds.tableName; - return ( -
- - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fdsConnLabel} – {fds.tableName} - - - - -
- ); - })} - - ); - })()} -
-
- )} - {/* ── Feature Data header ── */}
@@ -1084,7 +947,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) => group={g} onToggleGroup={_toggleMandateGroup} onToggleFeature={_toggleFeatureNode} - onAddTable={_addFeatureTable} + onEnsureFds={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} onToggleParentGroup={_toggleParentGroup} @@ -1094,6 +957,13 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) => expandedParentGroups={expandedParentGroups} loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} + onSendToChat={onSendToChat_FeatureSource} + featureDataSources={featureDataSources} + onCycleScope={_cycleFeatureScope} + onToggleNeutralize={_toggleFeatureNeutralize} + onToggleNeutralizeField={_toggleNeutralizeField} + onRemoveFds={_removeFeatureDataSource} + featureTree={featureTree} /> ))}
@@ -1102,17 +972,38 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) => /* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ +function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined { + const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined; + return dataSources.find(ds => + ds.connectionId === node.connectionId && + ds.path === (node.path || '/') && + (!expectedSourceType || ds.sourceType === expectedSourceType), + ); +} + interface _TreeNodeViewProps { node: TreeNode; depth: number; onToggle: (node: TreeNode) => void; - onAdd: (node: TreeNode) => void; + onEnsureDs: (node: TreeNode) => Promise; isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; addingPath: string | null; + dataSources: UdbDataSource[]; + onCycleScope: (ds: UdbDataSource) => void; + onToggleNeutralize: (ds: UdbDataSource) => void; + onRemoveDs: (dsId: string) => void; + onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; + scopeCycleTitle: (scope: string) => string; + selectedKeys: Set; + onSelect: (node: TreeNode, e: React.MouseEvent) => void; + inheritedScope?: string; + inheritedNeutralize?: boolean; } const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ - node, depth, onToggle, onAdd, isAdded, addingPath, + node, depth, onToggle, onEnsureDs, isAdded, addingPath, + dataSources, onCycleScope, onToggleNeutralize, onRemoveDs, onSendToChat, scopeCycleTitle, + selectedKeys, onSelect, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1120,16 +1011,58 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ const chevron = hasChildren ? (node.expanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'; - const canAdd = node.type === 'folder' || node.type === 'service'; - const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); - const isAdding = addingPath === node.key; + const ds = _findDs(dataSources, node); + + const effectiveScope = ds?.scope ?? inheritedScope; + const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false; + const childInheritedScope = ds?.scope ?? inheritedScope; + const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize; + + const _dragPayload = { + connectionId: node.connectionId, + sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', + path: node.path || '/', + label: node.label, + displayPath: node.displayPath || node.label, + nodeType: node.type, + }; + + const _chatPayload = { + connectionId: node.connectionId, + sourceType: node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : node.authority || '', + path: node.path || '/', + label: node.label, + displayPath: node.displayPath || node.label, + }; + + const connColor = ds ? _getSourceColor(ds.sourceType) : undefined; + const isSelected = selectedKeys.has(node.key); return (
{ if (hasChildren) onToggle(node); }} + 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', @@ -1140,7 +1073,13 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ paddingBottom: 3, cursor: hasChildren ? 'pointer' : 'default', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + 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', }} @@ -1153,31 +1092,72 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, - fontWeight: node.type === 'connection' ? 600 : 400, + fontWeight: (node.type === 'connection' || ds) ? 600 : 400, }}> {node.label} - {canAdd && hovered && !alreadyAdded && ( + + {/* Chat-Senden: always visible */} + + + {/* Scope: own DS → cycle, no DS → create DS then cycle */} + + + {/* Neutralize: own DS → toggle, no DS → create DS then toggle */} + + + {/* Remove: only when DS exists */} + {ds && ( )} - {canAdd && alreadyAdded && ( - - {'\u2713'} - - )} +
{node.expanded && node.children && node.children.length > 0 && ( @@ -1188,9 +1168,19 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ node={child} depth={depth + 1} onToggle={onToggle} - onAdd={onAdd} + onEnsureDs={onEnsureDs} isAdded={isAdded} addingPath={addingPath} + dataSources={dataSources} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onRemoveDs={onRemoveDs} + onSendToChat={onSendToChat} + scopeCycleTitle={scopeCycleTitle} + selectedKeys={selectedKeys} + onSelect={onSelect} + inheritedScope={childInheritedScope} + inheritedNeutralize={childInheritedNeutralize} /> ))}
@@ -1207,11 +1197,20 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ /* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ -interface _MandateGroupViewProps { +interface _FdsActionProps { + featureDataSources: UdbFeatureDataSource[]; + onCycleScope: (fds: UdbFeatureDataSource) => void; + onToggleNeutralize: (fds: UdbFeatureDataSource) => void; + onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; + onRemoveFds: (fdsId: string) => void; + featureTree: MandateGroupNode[]; +} + +interface _MandateGroupViewProps extends _FdsActionProps { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; @@ -1221,12 +1220,15 @@ interface _MandateGroupViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ - group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, + group, onToggleGroup, onToggleFeature, onEnsureFds, isTableAdded, addingKey, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, + expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, + featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + onRemoveFds, featureTree, }) => { const [hovered, setHovered] = useState(false); const chevron = group.expanded ? '\u25BE' : '\u25B8'; @@ -1260,7 +1262,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ key={fNode.featureInstanceId} node={fNode} onToggle={onToggleFeature} - onAddTable={onAddTable} + onEnsureFds={onEnsureFds} isTableAdded={isTableAdded} addingKey={addingKey} onToggleParentGroup={onToggleParentGroup} @@ -1270,6 +1272,13 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ expandedParentGroups={expandedParentGroups} loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} + onSendToChat={onSendToChat} + featureDataSources={featureDataSources} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onToggleNeutralizeField={onToggleNeutralizeField} + onRemoveFds={onRemoveFds} + featureTree={featureTree} /> ))}
@@ -1280,10 +1289,10 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ /* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ -interface _FeatureNodeViewProps { +interface _FeatureNodeViewProps extends _FdsActionProps { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; isTableAdded: (featureInstanceId: string, tableName: string) => boolean; addingKey: string | null; onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; @@ -1293,17 +1302,24 @@ interface _FeatureNodeViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ - node, onToggle, onAddTable, isTableAdded, addingKey, + node, onToggle, onEnsureFds, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, + expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, + featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + onRemoveFds, featureTree, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; + const wildcardFds = featureDataSources.find( + f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, + ); + const parentTables = (node.tables || []).filter(t => t.isParent); const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable); @@ -1313,11 +1329,27 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ onClick={() => onToggle(node)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + const payload = JSON.stringify({ + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + objectKey: `data.feature.${node.featureCode}.*`, + label: node.label, + }); + e.dataTransfer.setData('application/feature-source', payload); + e.dataTransfer.setData('text/plain', node.label); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + background: wildcardFds + ? (hovered ? '#ede7f6' : '#7b1fa208') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), + borderLeft: wildcardFds ? '3px solid #7b1fa2' : undefined, transition: 'background 0.1s', userSelect: 'none', }} > @@ -1333,6 +1365,71 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} + + {/* Chat: always visible */} + + + {/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */} + + + {/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */} + + + {/* Remove: only when wildcard-FDS exists */} + {wildcardFds && ( + + )} +
{node.expanded && node.tables && node.tables.length > 0 && ( @@ -1362,21 +1459,42 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)} isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)} addingParentKey={addingParentKey} + onSendToChat={onSendToChat} + featureDataSources={featureDataSources} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onToggleNeutralizeField={onToggleNeutralizeField} + onRemoveFds={onRemoveFds} + featureTree={featureTree} + inheritedScope={wildcardFds?.scope} + inheritedNeutralize={wildcardFds?.neutralize} /> ); })} {/* Standalone tables (not part of any hierarchy) */} - {standaloneTables.map(table => ( - <_FeatureTableRow - key={table.objectKey} - featureNode={node} - table={table} - onAdd={onAddTable} - isAdded={isTableAdded(node.featureInstanceId, table.tableName)} - isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} - /> - ))} + {standaloneTables.map(table => { + const fds = featureDataSources.find( + f => f.featureInstanceId === node.featureInstanceId && f.tableName === table.tableName && !f.recordFilter, + ); + return ( + <_FeatureTableRow + key={table.objectKey} + featureNode={node} + table={table} + onEnsureFds={onEnsureFds} + onSendToChat={onSendToChat} + fds={fds} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onToggleNeutralizeField={onToggleNeutralizeField} + onRemoveFds={onRemoveFds} + featureTree={featureTree} + inheritedScope={wildcardFds?.scope} + inheritedNeutralize={wildcardFds?.neutralize} + /> + ); + })}
)} @@ -1394,62 +1512,275 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; - onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isAdded: boolean; - isAdding: boolean; + onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + fds?: UdbFeatureDataSource; + onCycleScope?: (fds: UdbFeatureDataSource) => void; + onToggleNeutralize?: (fds: UdbFeatureDataSource) => void; + onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; + onRemoveFds?: (fdsId: string) => void; + featureTree?: MandateGroupNode[]; + inheritedScope?: string; + inheritedNeutralize?: boolean; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, onAdd, isAdded, isAdding, + featureNode, table, onEnsureFds, onSendToChat, + fds, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + onRemoveFds, featureTree, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); + const [fieldsExpanded, setFieldsExpanded] = useState(false); const tableLabel = table.label || table.tableName; + const effectiveScope = fds?.scope ?? inheritedScope; + const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false; + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + tableName: table.tableName, + objectKey: table.objectKey, + label: table.label || table.tableName, + }; + + const resolvedFields = featureTree ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) : table.fields; + const neutralizedCount = fds?.neutralizeFields?.length ?? 0; + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', tableLabel); + e.dataTransfer.effectAllowed = 'copy'; + }} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + borderRadius: 3, + background: fds + ? (hovered ? '#ede7f6' : '#7b1fa208') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), + transition: 'background 0.1s', userSelect: 'none', + }} + title={`${table.tableName}: ${table.fields.join(', ')}`} + > + { e.stopPropagation(); if (resolvedFields.length > 0) setFieldsExpanded(prev => !prev); }} + style={{ fontSize: 10, color: '#888', width: 12, textAlign: 'center', flexShrink: 0, cursor: resolvedFields.length > 0 ? 'pointer' : 'default' }} + > + {resolvedFields.length > 0 ? (fieldsExpanded ? '\u25BE' : '\u25B8') : '\u00A0\u00A0'} + + {'\uD83D\uDCC1'} + + {tableLabel} + {neutralizedCount > 0 && ( + ({neutralizedCount} {t('Felder')}) + )} + + + {/* Chat: always visible */} + + + {/* Scope: own FDS → cycle, otherwise create then cycle */} + + + {/* Neutralize: own FDS → toggle, otherwise create then toggle */} + + + {/* Remove: only when FDS exists */} + {fds && onRemoveFds && ( + + )} + +
+ + {/* Expandable field sub-nodes */} + {fieldsExpanded && resolvedFields.length > 0 && ( +
+ {resolvedFields.map(field => { + const isNeutralized = (fds?.neutralizeFields || []).includes(field); + return ( + <_FeatureFieldRow + key={field} + featureNode={featureNode} + table={table} + fieldName={field} + isNeutralized={isNeutralized || effectiveNeutralize} + fds={fds} + onToggleNeutralizeField={onToggleNeutralizeField} + onSendToChat={onSendToChat} + inheritedScope={fds?.scope ?? inheritedScope} + /> + ); + })} +
+ )} +
+ ); +}; + +/* ─── FeatureFieldRow (single field under a table) ────────────────────── */ + +interface _FeatureFieldRowProps { + featureNode: FeatureConnectionNode; + table: FeatureTableNode; + fieldName: string; + isNeutralized: boolean; + fds?: UdbFeatureDataSource; + onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + inheritedScope?: string; +} + +const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ + featureNode, table, fieldName, isNeutralized, fds, onToggleNeutralizeField, onSendToChat, inheritedScope, +}) => { + const { t } = useLanguage(); + const [hovered, setHovered] = useState(false); + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + tableName: table.tableName, + objectKey: table.objectKey, + label: `${table.label || table.tableName}.${fieldName}`, + fieldName, + }; + return (
setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', `${table.tableName}.${fieldName}`); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + paddingLeft: 56, paddingRight: 4, paddingTop: 2, paddingBottom: 2, borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + background: isNeutralized + ? (hovered ? '#f3e5f5' : '#f3e5f508') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), transition: 'background 0.1s', userSelect: 'none', + fontSize: 11, }} - title={`${table.tableName}: ${table.fields.join(', ')}`} > - {'\uD83D\uDCC1'} - - {tableLabel} + {'\u2514'} + + {fieldName} - {hovered && !isAdded && ( + + {/* Chat: always visible */} + + + {/* Neutralize: own FDS → clickable, otherwise dimmed */} + {fds && onToggleNeutralizeField ? ( - )} - {isAdded && ( - - {'\u2713'} + ) : ( + + {'\uD83D\uDD12'} )} + + {/* Scope: inherited indicator */} + + {_SCOPE_ICONS[inheritedScope || 'personal']} +
); }; /* ─── ParentGroupView (parent table → parent records) ────────────────── */ -interface _ParentGroupViewProps { +interface _ParentGroupViewProps extends _FdsActionProps { featureNode: FeatureConnectionNode; parentTable: FeatureTableNode; label: string; @@ -1463,11 +1794,17 @@ interface _ParentGroupViewProps { onAddRecord: (record: ParentRecordNode) => void; isRecordAdded: (recordId: string) => boolean; addingParentKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + inheritedScope?: string; + inheritedNeutralize?: boolean; } const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({ featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables, onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey, + onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, + onToggleNeutralizeField: _onToggleNeutralizeField, onRemoveFds, + featureTree: _featureTreeRef, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1503,19 +1840,32 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({ {expanded && records && records.length > 0 && (
- {records.map(record => ( - <_ParentRecordRow - key={record.id} - featureNode={featureNode} - record={record} - childTables={childTables} - allTables={allTables} - onToggle={() => onToggleRecord(record.id)} - onAdd={() => onAddRecord(record)} - isAdded={isRecordAdded(record.id)} - isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`} - /> - ))} + {records.map(record => { + const recordFds = featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId + && f.recordFilter?.id === record.id, + ); + return ( + <_ParentRecordRow + key={record.id} + featureNode={featureNode} + record={record} + childTables={childTables} + allTables={allTables} + onToggle={() => onToggleRecord(record.id)} + onAdd={() => onAddRecord(record)} + isAdded={isRecordAdded(record.id)} + isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`} + onSendToChat={onSendToChat} + fds={recordFds} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onRemoveFds={onRemoveFds} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + /> + ); + })}
)} @@ -1539,11 +1889,20 @@ interface _ParentRecordRowProps { onAdd: () => void; isAdded: boolean; isAdding: boolean; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + fds?: UdbFeatureDataSource; + onCycleScope?: (fds: UdbFeatureDataSource) => void; + onToggleNeutralize?: (fds: UdbFeatureDataSource) => void; + onRemoveFds?: (fdsId: string) => void; + inheritedScope?: string; + inheritedNeutralize?: boolean; } const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ - featureNode: _featureNode, record, childTables, allTables: _allTables, - onToggle, onAdd, isAdded, isAdding, + featureNode, record, childTables, allTables: _allTables, + onToggle, + onSendToChat, fds, onCycleScope, onToggleNeutralize, onRemoveFds, + inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1555,11 +1914,26 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ onClick={onToggle} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + const payload = JSON.stringify({ + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + objectKey: `data.feature.${featureNode.featureCode}.${record.tableName || '*'}`, + label: record.displayLabel, + }); + e.dataTransfer.setData('application/feature-source', payload); + e.dataTransfer.setData('text/plain', record.displayLabel); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + 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(', ')} @@ -1568,29 +1942,81 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ {chevron}
{'\uD83D\uDCCB'} - + {record.displayLabel} - {hovered && !isAdded && ( + + {/* Chat: always visible */} + + + {/* Scope: own FDS → clickable, otherwise dimmed */} + {fds && onCycleScope ? ( - )} - {isAdded && ( - - {'\u2713'} + ) : ( + + {_SCOPE_ICONS[inheritedScope || 'personal']} )} + + {/* Neutralize: own FDS → clickable, otherwise dimmed */} + {fds && onToggleNeutralize ? ( + + ) : ( + + {'\uD83D\uDD12'} + + )} + + {/* Remove: only when FDS exists */} + {fds && onRemoveFds && ( + + )} + {record.expanded && ( diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 35c5e00..b872405 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -14,6 +14,20 @@ export interface UdbContext { userId?: string; } +export interface AddToChat_FileItem { + id: string; + type: 'file' | 'folder'; + name: string; +} + +export interface AddToChat_FeatureSource { + featureInstanceId: string; + featureCode: string; + tableName?: string; + objectKey: string; + label: string; +} + interface UnifiedDataBarProps { context: UdbContext; activeTab?: UdbTab; @@ -27,6 +41,9 @@ interface UnifiedDataBarProps { onChatDragStart?: (chatId: string, event: React.DragEvent) => void; onFileSelect?: (fileId: string, fileName?: string) => void; onSourcesChanged?: () => void; + onSendToChat_Files?: (items: AddToChat_FileItem[]) => void; + onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void; + onAttachDataSource?: (dsId: string) => void; className?: string; } @@ -52,6 +69,9 @@ const UnifiedDataBar: React.FC = ({ onChatDragStart, onFileSelect, onSourcesChanged, + onSendToChat_Files, + onSendToChat_FeatureSource, + onAttachDataSource, className, }) => { const { t } = useLanguage(); @@ -95,10 +115,16 @@ const UnifiedDataBar: React.FC = ({ )} {currentTab === 'sources' && !hideTabs?.includes('sources') && ( - + )} diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts index 83b7dfc..5789264 100644 --- a/src/components/UnifiedDataBar/index.ts +++ b/src/components/UnifiedDataBar/index.ts @@ -1,3 +1,3 @@ export { default as UnifiedDataBar } from './UnifiedDataBar'; -export type { UdbContext, UdbTab } from './UnifiedDataBar'; +export type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from './UnifiedDataBar'; export { useUdlContext } from './useUdlContext'; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index e7b7c60..eca461c 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -83,6 +83,8 @@ export const PAGE_ICONS: Record = { 'page.admin.automation-logs': , 'page.admin.logs': , 'page.admin.languages': , + 'page.admin.databaseHealth': , + 'page.admin.database-health': , 'page.admin.demoConfig': , 'page.admin.demo-config': , 'page.admin.mandate-wizard': , diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 10a6a52..d38f190 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -815,8 +815,8 @@ export const ComplianceAuditPage: React.FC = () => { {/* ── Content View Modal ── */} {contentModal && ( -
setContentModal(null)}> -
e.stopPropagation()}> +
+

{t('AI-Audit Inhalt')}

diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index c4feb60..7795ba4 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -8,7 +8,7 @@ import { PENDING_INVITATION_KEY } from './InvitePage'; import OnboardingWizard from '../components/OnboardingWizard'; import styles from './Login.module.css'; - +import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { useLanguage } from '../providers/language/LanguageContext'; @@ -131,6 +131,9 @@ function Login() { return (
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
= { teamsbot: , workspace: , commcoach: , + trustee: , }; /** Fallback when GET /store/features omits description (German i18n keys). */ @@ -27,6 +28,7 @@ const STORE_FEATURE_DESCRIPTION_FALLBACK: Record = { teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.', commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.', + trustee: 'Trustee: Intelligentes Dokumentenmanagement mit KI-gestützter Analyse und Verarbeitung.', }; function _storeCardDescription(feature: StoreFeature): string { diff --git a/src/pages/admin/AdminDatabaseHealthPage.module.css b/src/pages/admin/AdminDatabaseHealthPage.module.css new file mode 100644 index 0000000..b5dfcde --- /dev/null +++ b/src/pages/admin/AdminDatabaseHealthPage.module.css @@ -0,0 +1,6 @@ +/** + * AdminDatabaseHealthPage Styles + * + * Minimal — table rendering is handled by FormGeneratorTable. + * Only page-specific overrides live here. + */ diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx new file mode 100644 index 0000000..8781b70 --- /dev/null +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -0,0 +1,638 @@ +/** + * AdminDatabaseHealthPage + * + * SysAdmin-only page with two tabs: + * 1. Table Statistics — pg_stat data for every table across all databases + * 2. Orphan Cleanup — FK orphan detection with per-relation + batch cleanup + * + * Both tabs use FormGeneratorTable with a client-side pagination/sort/filter + * adapter (the backend returns all rows at once; the dataset is small enough). + */ + +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa'; +import api from '../../api'; +import styles from './Admin.module.css'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { useToast } from '../../contexts/ToastContext'; +import { useConfirm } from '../../hooks/useConfirm'; +import { Tabs } from '../../components/UiComponents/Tabs/Tabs'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; + + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableStat { + id: string; + db: string; + table: string; + estimatedRows: number; + totalSizeBytes: number; + indexSizeBytes: number; + lastVacuum: string | null; + lastAnalyze: string | null; +} + +interface OrphanEntry { + id: string; + sourceDb: string; + sourceTable: string; + sourceColumn: string; + targetDb: string; + targetTable: string; + targetColumn: string; + orphanCount: number; +} + +interface CleanResult { + db: string; + table: string; + column: string; + deleted: number; + error?: string; +} + +interface PaginationParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; +} + +interface PaginationMeta { + currentPage: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function _formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function _formatNumber(n: number): string { + return n.toLocaleString('de-CH'); +} + + +// --------------------------------------------------------------------------- +// useClientPagination — adapts a static array to FormGeneratorTable's +// hookData.refetch / hookData.pagination contract. +// --------------------------------------------------------------------------- + +function _useClientPagination>(allData: T[]) { + const [visibleData, setVisibleData] = useState([]); + const [pagination, setPagination] = useState({ + currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1, + }); + + const allDataRef = useRef(allData); + allDataRef.current = allData; + + const lastParamsRef = useRef({}); + + const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record) => { + let source = allDataRef.current; + if (crossFilters && Object.keys(crossFilters).length > 0) { + source = source.filter(row => { + for (const [key, val] of Object.entries(crossFilters)) { + if (val === undefined || val === null || val === '') continue; + const cell = String(row[key] ?? ''); + if (Array.isArray(val)) { + if (val.length > 0 && !val.includes(cell)) return false; + } else { + if (cell !== String(val)) return false; + } + } + return true; + }); + } + const seen = new Set(); + for (const row of source) { + const v = row[columnKey]; + if (v !== undefined && v !== null && String(v).trim()) { + seen.add(String(v)); + } + } + return Array.from(seen).sort(); + }, []); + + const refetch = useCallback(async (params?: PaginationParams) => { + const p = params || lastParamsRef.current; + lastParamsRef.current = p; + const source = allDataRef.current; + + const page = p.page || 1; + const pageSize = p.pageSize || 50; + const search = (p.search || '').toLowerCase(); + const filters = p.filters || {}; + const sorts = p.sort || []; + + // 1) Filter + let filtered = source.filter(row => { + for (const [key, val] of Object.entries(filters)) { + if (val === undefined || val === null || val === '') continue; + const cell = String(row[key] ?? ''); + if (Array.isArray(val)) { + if (val.length > 0 && !val.includes(cell)) return false; + } else { + if (cell !== String(val)) return false; + } + } + return true; + }); + + // 2) Search + if (search) { + filtered = filtered.filter(row => + Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search)), + ); + } + + // 3) Sort + if (sorts.length > 0) { + filtered.sort((a, b) => { + for (const s of sorts) { + const aVal = a[s.field]; + const bVal = b[s.field]; + let cmp = 0; + if (typeof aVal === 'number' && typeof bVal === 'number') { + cmp = aVal - bVal; + } else { + cmp = String(aVal ?? '').localeCompare(String(bVal ?? '')); + } + if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp; + } + return 0; + }); + } + + // 4) Paginate + const totalItems = filtered.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const safePage = Math.min(page, totalPages); + const start = (safePage - 1) * pageSize; + const paged = filtered.slice(start, start + pageSize); + + setVisibleData(paged); + setPagination({ currentPage: safePage, pageSize, totalItems, totalPages }); + }, []); + + // Re-apply whenever allData changes + useEffect(() => { + refetch(lastParamsRef.current); + }, [allData, refetch]); + + return { visibleData, pagination, refetch, fetchFilterValues }; +} + + +// --------------------------------------------------------------------------- +// StatsTab +// --------------------------------------------------------------------------- + +const StatsTab: React.FC = () => { + const { t } = useLanguage(); + const [allStats, setAllStats] = useState([]); + const [loading, setLoading] = useState(false); + const [dbFilter, setDbFilter] = useState(''); + + const _fetchStats = useCallback(async () => { + try { + setLoading(true); + const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : ''; + const res = await api.get(`/api/admin/database-health/stats${params}`); + const rows = (res.data.stats || []).map((s: any, i: number) => ({ + ...s, + id: `${s.db}-${s.table}-${i}`, + })); + setAllStats(rows); + } catch { + setAllStats([]); + } finally { + setLoading(false); + } + }, [dbFilter]); + + useEffect(() => { _fetchStats(); }, [_fetchStats]); + + const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allStats); + + const databases = useMemo( + () => Array.from(new Set(allStats.map(s => s.db))).sort(), + [allStats], + ); + + const totals = useMemo(() => { + let rows = 0, size = 0, idx = 0; + for (const s of allStats) { + rows += s.estimatedRows; + size += s.totalSizeBytes; + idx += s.indexSizeBytes; + } + return { rows, size, idx, tables: allStats.length, dbs: databases.length }; + }, [allStats, databases]); + + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'db', + label: t('Datenbank'), + sortable: true, + filterable: true, + searchable: true, + width: 200, + filterOptions: databases, + }, + { + key: 'table', + label: t('Tabelle'), + sortable: true, + searchable: true, + width: 200, + }, + { + key: 'estimatedRows', + label: t('Zeilen (ca.)'), + type: 'number', + sortable: true, + width: 120, + formatter: (v: number) => _formatNumber(v), + }, + { + key: 'totalSizeBytes', + label: t('Total Size'), + type: 'number', + sortable: true, + width: 120, + formatter: (v: number) => _formatBytes(v), + }, + { + key: 'indexSizeBytes', + label: t('Index Size'), + type: 'number', + sortable: true, + width: 120, + formatter: (v: number) => _formatBytes(v), + }, + { + key: 'lastVacuum', + label: t('Last Vacuum'), + sortable: true, + width: 170, + formatter: (v: string | null) => v ?? '—', + }, + { + key: 'lastAnalyze', + label: t('Last Analyze'), + sortable: true, + width: 170, + formatter: (v: string | null) => v ?? '—', + }, + ], [t, databases]); + + return ( +
+ {/* Controls */} +
+
+ + +
+ +
+ + {/* Summary */} +
+ {t('{dbs} Datenbanken', { dbs: totals.dbs })} + {t('{tables} Tabellen', { tables: totals.tables })} + {t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })} + {t('Total {size}', { size: _formatBytes(totals.size) })} + {t('Index {size}', { size: _formatBytes(totals.idx) })} +
+ +
+ +
+
+ ); +}; + + +// --------------------------------------------------------------------------- +// OrphansTab +// --------------------------------------------------------------------------- + +const OrphansTab: React.FC = () => { + const { t } = useLanguage(); + const toast = useToast(); + const { confirm, ConfirmDialog } = useConfirm(); + + const [allOrphans, setAllOrphans] = useState([]); + const [loading, setLoading] = useState(false); + const [cleaning, setCleaning] = useState(null); + const [cleaningAll, setCleaningAll] = useState(false); + const [onlyProblems, setOnlyProblems] = useState(true); + const [dbFilter, setDbFilter] = useState(''); + + const _fetchOrphans = useCallback(async () => { + try { + setLoading(true); + const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : ''; + const res = await api.get(`/api/admin/database-health/orphans${params}`); + const rows = (res.data.orphans || []).map((o: any, i: number) => ({ + ...o, + id: `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}-${i}`, + })); + setAllOrphans(rows); + } catch { + setAllOrphans([]); + } finally { + setLoading(false); + } + }, [dbFilter]); + + useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]); + + const displayed = useMemo( + () => onlyProblems ? allOrphans.filter(o => o.orphanCount > 0) : allOrphans, + [allOrphans, onlyProblems], + ); + + const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(displayed); + + const databases = useMemo( + () => Array.from(new Set(allOrphans.map(o => o.sourceDb))).sort(), + [allOrphans], + ); + + const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]); + + const _cleanOne = async (o: OrphanEntry) => { + const ok = await confirm( + t('Orphans bereinigen'), + t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }), + ); + if (!ok) return; + const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`; + setCleaning(key); + try { + const res = await api.post('/api/admin/database-health/orphans/clean', { + db: o.sourceDb, + table: o.sourceTable, + column: o.sourceColumn, + }); + toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted })); + _fetchOrphans(); + } catch (err: any) { + toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen')); + } finally { + setCleaning(null); + } + }; + + const _cleanAll = async () => { + const ok = await confirm( + t('Alle Orphans bereinigen'), + t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', { + count: totalOrphans, + relations: allOrphans.filter(o => o.orphanCount > 0).length, + }), + ); + if (!ok) return; + setCleaningAll(true); + try { + const res = await api.post('/api/admin/database-health/orphans/clean-all'); + const results: CleanResult[] = res.data.results || []; + const totalDeleted = results.reduce((s, r) => s + r.deleted, 0); + const errors = results.filter(r => r.error); + if (errors.length > 0) { + toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length })); + } else { + toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted })); + } + _fetchOrphans(); + } catch (err: any) { + toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen')); + } finally { + setCleaningAll(false); + } + }; + + const columns: ColumnConfig[] = useMemo(() => [ + { + key: 'sourceDb', + label: t('Source DB'), + sortable: true, + filterable: true, + searchable: true, + width: 180, + filterOptions: databases, + }, + { + key: 'sourceTable', + label: t('Tabelle'), + sortable: true, + searchable: true, + width: 180, + }, + { + key: 'sourceColumn', + label: t('FK-Spalte'), + sortable: true, + searchable: true, + width: 150, + }, + { + key: 'targetTable', + label: t('Referenz'), + sortable: true, + width: 220, + formatter: (_val: string, row: OrphanEntry) => { + const isCrossDb = row.sourceDb !== row.targetDb; + return ( + + {row.targetTable}.{row.targetColumn} + {isCrossDb && ( + + {t('cross-db')} + + )} + + ); + }, + }, + { + key: 'orphanCount', + label: t('Orphans'), + type: 'number', + sortable: true, + width: 100, + formatter: (v: number) => ( + 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}> + {_formatNumber(v)} + + ), + }, + ], [t, databases]); + + return ( +
+ + + {/* Controls */} +
+
+ + +
+
+ +
+
+ + {totalOrphans > 0 && ( + + )} +
+
+ + {totalOrphans > 0 && ( +
+ + {t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', { + count: _formatNumber(totalOrphans), + relations: allOrphans.filter(o => o.orphanCount > 0).length, + })} +
+ )} + +
+ , + onClick: (row: OrphanEntry) => _cleanOne(row), + visible: (row: OrphanEntry) => row.orphanCount > 0, + loading: (row: OrphanEntry) => cleaning === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}` || cleaningAll, + title: t('Orphans löschen'), + }, + ]} + hookData={{ + refetch, + pagination, + fetchFilterValues, + }} + emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')} + /> +
+
+ ); +}; + + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export const AdminDatabaseHealthPage: React.FC = () => { + const { t } = useLanguage(); + + const tabs = useMemo(() => [ + { + id: 'stats', + label: t('Statistiken'), + content: , + }, + { + id: 'orphans', + label: t('Orphan Cleanup'), + content: , + }, + ], [t]); + + return ( +
+
+
+

{t('Datenbank-Gesundheit')}

+

{t('Tabellenstatistiken und verwaiste Datensätze')}

+
+
+ + +
+ ); +}; + +export default AdminDatabaseHealthPage; diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 0f18c62..f4343ca 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -15,7 +15,6 @@ import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/ import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; import { ChatbotConfigSection } from './ChatbotConfigSection'; -import { DropdownSelect } from '../../components/UiComponents/DropdownSelect'; import { TextField } from '../../components/UiComponents/TextField'; import styles from './Admin.module.css'; @@ -512,8 +511,8 @@ export const AdminFeatureAccessPage: React.FC = () => { {/* Create Instance Modal */} {showCreateModal && ( -
setShowCreateModal(false)}> -
e.stopPropagation()}> +
+

{t('Neue Feature-Instanz erstellen')}

) : (
- {/* Feature Code Selector - Required for chatbot config */} + {/* Feature Code Selector — buttons instead of dropdown */}
- ({ - id: f.code, - label: f.label || f.code, - value: f.code - }))} - selectedItemId={createFeatureCode} - onSelect={(item) => { - const selectedCode = item?.value || ''; - setCreateFeatureCode(selectedCode); - // Reset chatbot config when switching - setChatbotConnectors(['preprocessor']); - setChatbotSystemPrompt(''); - setChatbotEnableWebResearch(true); - setChatbotAllowedProviders([]); - }} - placeholder={t('Feature-Auswahl erforderlich')} - className={styles.configSelect} - /> - {!createFeatureCode && ( -

- {t('Bitte wählen Sie ein Feature aus, um fortzufahren.')} -

- )} +
+ {features.map(f => ( + + ))} +
{/* Chatbot Configuration Title - Show when chatbot is selected */} @@ -634,8 +636,8 @@ export const AdminFeatureAccessPage: React.FC = () => { {/* Edit Instance Modal */} {showEditModal && editingInstance && ( -
{ setShowEditModal(false); setEditingInstance(null); }}> -
e.stopPropagation()}> +
+

{t('Feature-Instanz bearbeiten')}

) : ( { {/* Add User Modal */} {showAddModal && ( -
setShowAddModal(false)}> -
e.stopPropagation()}> +
+

{t('Benutzer zum Mandanten hinzufügen')}

{showAddModal && ( -
setShowAddModal(false)}> -
e.stopPropagation()}> +
+

{t('Benutzer hinzufügen')}

{editingFile && ( -
setEditingFile(null)}> -
e.stopPropagation()}> +
+

{t('Datei bearbeiten')}

diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 8e56068..38ea58f 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -242,8 +242,8 @@ export const PromptsPage: React.FC = () => { {/* Create Modal */} {showCreateModal && ( -
setShowCreateModal(false)}> -
e.stopPropagation()}> +
+

{t('Neuer Prompt')}

- {(dataSources.length > 0 || featureDataSources.length > 0) && ( -
- - {showSourcePicker && ( -
-
- Active Sources auswählen -
- {dataSources.map(ds => { - const isSelected = attachedDataSourceIds.includes(ds.id); - return ( -
_toggleDataSource(ds.id)} - style={{ - padding: '8px 12px', cursor: 'pointer', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 8, - background: isSelected ? '#e8f5e9' : 'transparent', - }} - onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }} - onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }} - > - - {isSelected ? '✓' : ''} - - - {ds.label || ds.path || ds.id} - -
- ); - })} - {featureDataSources.length > 0 && ( - <> -
- Feature Data Sources -
- {featureDataSources.map(fds => { - const isSelected = attachedFeatureDataSourceIds.includes(fds.id); - return ( -
_toggleFeatureDataSource(fds.id)} - style={{ - padding: '8px 12px', cursor: 'pointer', fontSize: 13, - display: 'flex', alignItems: 'center', gap: 8, - background: isSelected ? '#f3e5f5' : 'transparent', - }} - onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }} - onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = ''; }} - > - - {isSelected ? '✓' : ''} - - - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fds.label || fds.featureCode} – {fds.tableName} - -
- ); - })} - - )} -
- )} -
- )} + {/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */} {onProviderSelectionChange && providerSelection && ( = ({ persistentInstance workspace.refreshFeatureDataSources(); }, [workspace]); + const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => { + setPendingFiles(prev => { + const existing = new Set(prev.map(f => f.fileId)); + const toAdd: PendingFile[] = []; + for (const item of items) { + if (!existing.has(item.id)) { + toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type }); + existing.add(item.id); + } + } + return [...prev, ...toAdd]; + }); + }, []); + + const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { + try { + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: params.featureInstanceId, + featureCode: params.featureCode, + tableName: params.tableName || '', + objectKey: params.objectKey, + label: params.label, + }); + workspace.refreshFeatureDataSources(); + } catch (err) { + console.error('Failed to add feature source to chat:', err); + } + }, [instanceId, workspace]); + + const [pendingAttachDsId, setPendingAttachDsId] = useState(''); + const _handleAttachDataSource = useCallback((dsId: string) => { + setPendingAttachDsId(dsId); + }, []); + + const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => { + 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) { + setPendingAttachDsId(newId); + workspace.refreshDataSources(); + } + } catch (err) { + console.error('Failed to drop data source to chat:', err); + } + }, [instanceId, workspace]); + const _leftPanelBody = ( = ({ persistentInstance onDeleteChat={_handleDeleteChat} onFileSelect={_handleFileSelect} onSourcesChanged={_handleSourcesChanged} + onSendToChat_Files={_handleSendToChat_Files} + onSendToChat_FeatureSource={_handleSendToChat_FeatureSource} + onAttachDataSource={_handleAttachDataSource} /> ); @@ -461,6 +517,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance onProviderSelectionChange={setProviderSelection} isMobile={isMobile} onTreeItemsDrop={_handleTreeItemsDrop} + onFeatureSourceDrop={_handleSendToChat_FeatureSource} + onDataSourceDrop={_handleDataSourceDrop} + pendingAttachDsId={pendingAttachDsId} + onPendingAttachDsConsumed={() => setPendingAttachDsId('')} onPasteAsFile={_uploadAndAttach} draftAppend={draftAppend} onDraftAppendConsumed={() => setDraftAppend('')}