From d55b24ab6abb571bf943b13c0722e7fe16cbdbf3 Mon Sep 17 00:00:00 2001 From: idittrich-valueon Date: Mon, 13 Apr 2026 12:11:29 +0200 Subject: [PATCH 01/15] readded clickup connector --- src/pages/basedata/ConnectionsPage.tsx | 34 +++++++++----------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index be8ff49..507cc16 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -15,10 +15,6 @@ import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; -/** Wenn false: keine neue ClickUp-Verbindung über diese Seite (Buttons inaktiv). */ -const isClickupConnectionUiEnabled = false; - - export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); @@ -197,9 +193,7 @@ export const ConnectionsPage: React.FC = () => { } }; - // Handle create ClickUp connection (UI kann per Flag abgeschaltet sein) const handleCreateClickup = async () => { - if (!isClickupConnectionUiEnabled) return; try { await createClickupConnectionAndAuth(); refetch(); @@ -253,9 +247,7 @@ export const ConnectionsPage: React.FC = () => {

{t('Verbindungen')}

- {isClickupConnectionUiEnabled - ? t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)') - : t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft)')} + {t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')}

@@ -290,17 +282,15 @@ export const ConnectionsPage: React.FC = () => { > Microsoft - {isClickupConnectionUiEnabled && ( - - )} + )}
@@ -336,9 +326,7 @@ export const ConnectionsPage: React.FC = () => { icon: , onClick: handleConnect, title: t('Verbinden'), - visible: (row: Connection) => - row.status !== 'active' && - (isClickupConnectionUiEnabled || row.authority !== 'clickup'), + visible: (row: Connection) => row.status !== 'active', loading: () => isConnecting, }, { From 92f293825ff181dd4ccc5734ac5d720abeda44c9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 22:56:22 +0200 Subject: [PATCH 02/15] fixes from demo1: compliance ui fgtable issues, nodes vertical, nodes editting logic to edit in all editors of a mmandate based on highest level of role --- .../editor/Automation2FlowEditor.tsx | 7 +- .../FlowEditor/editor/CanvasHeader.tsx | 17 ++- .../FlowEditor/editor/FlowCanvas.tsx | 107 ++++++++++++++---- src/pages/AutomationsDashboardPage.tsx | 57 +++++++++- src/pages/ComplianceAuditPage.tsx | 63 ++++++++--- src/pages/Settings.tsx | 13 +-- 6 files changed, 212 insertions(+), 52 deletions(-) diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index de4a245..37a1fdb 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -31,7 +31,7 @@ import { type AutoVersion, type AutoTemplateScope, } from '../../../api/workflowApi'; -import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; +import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; @@ -587,6 +587,10 @@ export const Automation2FlowEditor: React.FC = ({ in } }, [request, instanceId]); + const handleAutoLayout = useCallback(() => { + setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections)); + }, [canvasConnections]); + const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const renderSidebar = () => { @@ -708,6 +712,7 @@ export const Automation2FlowEditor: React.FC = ({ in templateSaving={templateSaving} onNewFromTemplate={() => setTemplatePickerOpen(true)} onWorkflowRename={handleWorkflowRename} + onAutoLayout={handleAutoLayout} />
diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx index a7ea722..3ffe702 100644 --- a/src/components/FlowEditor/editor/CanvasHeader.tsx +++ b/src/components/FlowEditor/editor/CanvasHeader.tsx @@ -3,7 +3,7 @@ */ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa'; +import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa'; import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; @@ -34,6 +34,7 @@ interface CanvasHeaderProps { templateSaving?: boolean; onNewFromTemplate?: () => void; onWorkflowRename?: (workflowId: string, newName: string) => void; + onAutoLayout?: () => void; } function _getStatusBadge(t: (key: string) => string): Record { @@ -68,6 +69,7 @@ export const CanvasHeader: React.FC = ({ workflows, templateSaving, onNewFromTemplate, onWorkflowRename, + onAutoLayout, }) => { const { t } = useLanguage(); const statusBadge = _getStatusBadge(t); @@ -216,6 +218,19 @@ export const CanvasHeader: React.FC = ({ workflows, {saving ? : t('Speichern')} + {onAutoLayout && ( + + )} + {/* Save as template */} {currentWorkflowId && onSaveAsTemplate && (
diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index c579f21..3589a2d 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -37,6 +37,75 @@ const NODE_WIDTH = 200; const NODE_HEIGHT = 72; const HANDLE_SIZE = 12; const HANDLE_OFFSET = HANDLE_SIZE / 2; +const LAYOUT_V_GAP = 80; +const LAYOUT_H_GAP = 60; + +/** + * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers. + * Disconnected nodes are appended as extra roots. + */ +export function computeAutoLayout( + nodes: CanvasNode[], + connections: CanvasConnection[], +): CanvasNode[] { + if (nodes.length === 0) return nodes; + + const inDegree = new Map(); + const children = new Map(); + for (const n of nodes) { + inDegree.set(n.id, 0); + children.set(n.id, []); + } + for (const c of connections) { + inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1); + children.get(c.sourceId)?.push(c.targetId); + } + + const layers: string[][] = []; + const layerOf = new Map(); + const queue: string[] = []; + for (const n of nodes) { + if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id); + } + + while (queue.length > 0) { + const batch: string[] = [...queue]; + queue.length = 0; + const layerIdx = layers.length; + layers.push(batch); + for (const id of batch) { + layerOf.set(id, layerIdx); + for (const childId of children.get(id) ?? []) { + const deg = (inDegree.get(childId) ?? 1) - 1; + inDegree.set(childId, deg); + if (deg === 0) queue.push(childId); + } + } + } + + const placed = new Set(layerOf.keys()); + for (const n of nodes) { + if (!placed.has(n.id)) { + const layerIdx = layers.length; + layers.push([n.id]); + layerOf.set(n.id, layerIdx); + } + } + + const startX = 40; + const startY = 40; + + return nodes.map((n) => { + const layer = layerOf.get(n.id) ?? 0; + const siblings = layers[layer]; + const idxInLayer = siblings.indexOf(n.id); + return { + ...n, + x: startX + idxInLayer * (NODE_WIDTH + LAYOUT_H_GAP), + y: startY + layer * (NODE_HEIGHT + LAYOUT_V_GAP), + }; + }); +} /** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */ function _checkConnectionCompatibility( @@ -164,26 +233,16 @@ export const FlowCanvas: React.FC = ({ nodes, const w = NODE_WIDTH; const h = NODE_HEIGHT; - const centerY = node.y + h / 2; + const centerX = node.x + w / 2; if (isOutput) { - if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' }; - if (ioCount === 2) { - return ioIndex === 0 - ? { x: node.x + w, y: node.y + h / 3, side: 'right' } - : { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' }; - } - const step = h / (ioCount + 1); - return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' }; + if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' }; + const step = w / (ioCount + 1); + return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' }; } else { - if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' }; - if (ioCount === 2) { - return ioIndex === 0 - ? { x: node.x, y: node.y + h / 3, side: 'left' } - : { x: node.x, y: node.y + (2 * h) / 3, side: 'left' }; - } - const step = h / (ioCount + 1); - return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' }; + if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; + const step = w / (ioCount + 1); + return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' }; } }, [] @@ -639,8 +698,8 @@ export const FlowCanvas: React.FC = ({ nodes, if (!srcNode || !tgtNode) return null; const src = getHandlePosition(srcNode, c.sourceHandle); const tgt = getHandlePosition(tgtNode, c.targetHandle); - const dx = tgt.x - src.x; - const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`; + const dy = tgt.y - src.y; + const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`; const isSelected = selectedConnectionId === c.id; const isWarning = connectionWarnings[c.id]; const strokeColor = isSelected @@ -756,12 +815,12 @@ export const FlowCanvas: React.FC = ({ nodes, key={index} className={styles.handleWrapper} style={{ - left: pos.side === 'left' ? -HANDLE_OFFSET : undefined, - right: pos.side === 'right' ? -HANDLE_OFFSET : undefined, - top: pos.y - node.y - HANDLE_OFFSET, + top: pos.side === 'top' ? -HANDLE_OFFSET : undefined, + bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined, + left: pos.x - node.x - HANDLE_OFFSET, }} > - {outputLabel && pos.side === 'right' && ( + {outputLabel && pos.side === 'bottom' && ( {outputLabel} )}
= ({ nodes, : undefined) } /> - {outputLabel && pos.side === 'left' && ( + {outputLabel && pos.side === 'top' && ( {outputLabel} )}
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index d229ffe..00e9110 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -18,6 +18,7 @@ import { formatUnixTimestamp } from '../utils/time'; import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi'; import api from '../api'; import { useLanguage } from '../providers/language/LanguageContext'; +import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; import styles from './admin/Admin.module.css'; // --------------------------------------------------------------------------- @@ -53,6 +54,7 @@ interface SystemWorkflow { id: string; mandateId: string; featureInstanceId: string; + featureCode?: string; label: string; active: boolean; isRunning?: boolean; @@ -72,6 +74,43 @@ interface SystemWorkflow { graph?: Record; } +const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']); + +const _ROLE_PRIORITY: Record = { admin: 3, user: 2, viewer: 1 }; + +function _bestEditorInstance( + dynamicBlock: DynamicBlock | null, + mandateId: string, +): { instanceId: string; featureCode: string } | null { + if (!dynamicBlock) return null; + const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId); + if (!mandate) return null; + + let best: { instanceId: string; featureCode: string; score: number } | null = null; + for (const feat of mandate.features) { + for (const inst of feat.instances) { + const fc = inst.featureCode + || feat.uiComponent.replace(/^feature\./, ''); + if (!_FEATURES_WITH_EDITOR.has(fc)) continue; + let score = 0; + if (inst.isAdmin) { + score = 10; + } else { + for (const v of inst.views) { + const key = v.objectKey || ''; + for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) { + if (key.endsWith(suffix) && prio > score) score = prio; + } + } + } + if (!best || score > best.score) { + best = { instanceId: inst.id, featureCode: fc, score }; + } + } + } + return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null; +} + function _formatTs(ts?: number): string { if (ts == null || ts <= 0) return '—'; const sec = ts < 1e12 ? ts : ts / 1000; @@ -664,6 +703,7 @@ const _WorkflowsTab: React.FC = () => { const { request } = useApiRequest(); const { showSuccess, showError } = useToast(); const { prompt: promptInput, PromptDialog } = usePrompt(); + const { dynamicBlock } = useNavigation(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); @@ -718,10 +758,19 @@ const _WorkflowsTab: React.FC = () => { }, [hasRunningWorkflows, _load]); const _handleEdit = useCallback((row: SystemWorkflow) => { - if (!row.mandateId || !row.featureInstanceId) return; - const fc = (row as any).featureCode || 'graphicalEditor'; - navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); - }, [navigate]); + if (!row.mandateId) return; + const fc = row.featureCode || ''; + if (_FEATURES_WITH_EDITOR.has(fc)) { + navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); + return; + } + const editor = _bestEditorInstance(dynamicBlock, row.mandateId); + if (!editor) { + showError(t('Kein Editor verfügbar für diesen Mandanten')); + return; + } + navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`); + }, [navigate, showError, t, dynamicBlock]); const _handleDelete = useCallback(async (workflowId: string): Promise => { try { diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 3d18ac9..10a6a52 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -133,7 +133,7 @@ export const ComplianceAuditPage: React.FC = () => { const [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const [selectedMandateId, setSelectedMandateId] = useState(null); - const [activeTab, setActiveTab] = useState('ai-log'); + const [activeTab, setActiveTab] = useState('audit-log'); // ── Tab A: AI-Log state ── const [aiEntries, setAiEntries] = useState([]); @@ -193,8 +193,13 @@ export const ComplianceAuditPage: React.FC = () => { const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE; const offset = (page - 1) * pageSize; + const params: any = { limit: pageSize, offset }; + if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort); + if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters); + if (paginationParams?.search) params.search = paginationParams.search; + const { data } = await api.get('/api/audit/ai-log', { - params: { limit: pageSize, offset }, + params, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; @@ -220,8 +225,13 @@ export const ComplianceAuditPage: React.FC = () => { const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE; const offset = (page - 1) * pageSize; + const params: any = { limit: pageSize, offset }; + if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort); + if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters); + if (paginationParams?.search) params.search = paginationParams.search; + const { data } = await api.get('/api/audit/log', { - params: { limit: pageSize, offset }, + params, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; @@ -262,8 +272,13 @@ export const ComplianceAuditPage: React.FC = () => { const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE; const offset = (page - 1) * pageSize; + const neutParams: any = { limit: pageSize, offset }; + if (paginationParams?.sort?.length) neutParams.sort = JSON.stringify(paginationParams.sort); + if (paginationParams?.filters && Object.keys(paginationParams.filters).length) neutParams.filters = JSON.stringify(paginationParams.filters); + if (paginationParams?.search) neutParams.search = paginationParams.search; + const { data } = await api.get('/api/audit/neutralization-mappings', { - params: { limit: pageSize, offset }, + params: neutParams, headers: _mandateHeaders(), }); const items: any[] = data?.items ?? []; @@ -410,8 +425,8 @@ export const ComplianceAuditPage: React.FC = () => { formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'), }, { - key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 130, - formatter: (val: any, row: any) => row?.instanceLabel || val || '–', + key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, + formatter: (val: any, row: any) => val || row?.featureCode || '–', }, { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, { @@ -467,12 +482,12 @@ export const ComplianceAuditPage: React.FC = () => { { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 }, { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, { - key: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, - formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', + key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, + formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'), }, { - key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, - formatter: (val: any) => val || '–', + key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, + formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'), }, { key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140, @@ -480,26 +495,46 @@ export const ComplianceAuditPage: React.FC = () => { }, ], [t]); + // ── fetchFilterValues for autofilter dropdowns ── + + const _makeFetchFilterValues = useCallback( + (endpoint: string) => async (columnKey: string, crossFilters?: Record) => { + if (!selectedMandateId) return []; + try { + const params: any = { mode: 'filterValues', column: columnKey }; + if (crossFilters && Object.keys(crossFilters).length) { + params.filters = JSON.stringify(crossFilters); + } + const { data } = await api.get(endpoint, { params, headers: _mandateHeaders() }); + return Array.isArray(data) ? data : []; + } catch { return []; } + }, + [selectedMandateId], // eslint-disable-line react-hooks/exhaustive-deps + ); + // ── hookData for FormGeneratorTable ── const aiLogHookData = useMemo(() => ({ refetch: _loadAiLog, pagination: aiPagination, - }), [_loadAiLog, aiPagination]); + fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'), + }), [_loadAiLog, aiPagination, _makeFetchFilterValues]); const auditLogHookData = useMemo(() => ({ refetch: _loadAuditLog, pagination: auditPagination, - }), [_loadAuditLog, auditPagination]); + fetchFilterValues: _makeFetchFilterValues('/api/audit/log'), + }), [_loadAuditLog, auditPagination, _makeFetchFilterValues]); const neutHookData = useMemo(() => ({ refetch: _loadNeutMappings, pagination: neutPagination, - }), [_loadNeutMappings, neutPagination]); + fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'), + }), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]); // ── Render ── - const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization']; + const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats']; return (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 11c381d..0d3e523 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,15 +17,14 @@ import styles from './Settings.module.css'; // TYPES // ============================================================================= -type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy'; +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] { return [ - { key: 'profile', label: t('Tab Profil') }, - { key: 'appearance', label: t('Tab Darstellung') }, - { key: 'voice', label: t('Tab Stimme & Sprache') }, - { key: 'neutralization', label: t('Tab Neutralisierung') }, - { key: 'privacy', label: t('Tab Datenschutz') }, + { key: 'profile', label: t('Profil') }, + { key: 'appearance', label: t('Darstellung') }, + { key: 'voice', label: t('Stimme & Sprache') }, + { key: 'privacy', label: t('Datenschutz') }, ]; } @@ -563,8 +562,6 @@ export const SettingsPage: React.FC = () => { {activeTab === 'voice' && } - {activeTab === 'neutralization' && } - {activeTab === 'privacy' && (

{t('Datenschutz')}

From d743222a2bf8f5d58986caa5c17e215f9fc96c11 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 23:02:54 +0200 Subject: [PATCH 03/15] fix --- src/pages/Settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 0d3e523..df0752d 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -314,7 +314,7 @@ interface NeutralizationMapping { featureInstanceId?: string; } -const NeutralizationMappingsTab: React.FC = () => { +const _NeutralizationMappingsTab: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const [mappings, setMappings] = useState([]); From 7a0880e0641743bbce573285647e1402bd3429f4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 23:06:20 +0200 Subject: [PATCH 04/15] fix2 --- src/pages/Settings.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index df0752d..dd2ede9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -314,7 +314,7 @@ interface NeutralizationMapping { featureInstanceId?: string; } -const _NeutralizationMappingsTab: React.FC = () => { +const NeutralizationMappingsTab: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); const [mappings, setMappings] = useState([]); @@ -563,16 +563,19 @@ export const SettingsPage: React.FC = () => { {activeTab === 'voice' && } {activeTab === 'privacy' && ( -
-

{t('Datenschutz')}

-

- {t('Datenschutzbeschreibung')} -

-
-

{t('Datenexport, Portabilität und Kontolöschung')}

-
{t('GDPR öffnen')}
-
-
+ <> +
+

{t('Datenschutz')}

+

+ {t('Datenschutzbeschreibung')} +

+
+

{t('Datenexport, Portabilität und Kontolöschung')}

+
{t('GDPR öffnen')}
+
+
+ + )} From a79da7c33782e0fe3d4f7cf8a242d25d122aee18 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 16 Apr 2026 14:20:29 +0200 Subject: [PATCH 05/15] fixes comcoach --- .../CommcoachDashboardView.module.css | 87 ++++++++++ .../commcoach/CommcoachDashboardView.tsx | 15 +- .../commcoach/CommcoachDossierView.module.css | 153 ++++++++++++++++++ .../views/commcoach/CommcoachDossierView.tsx | 10 ++ .../views/commcoach/CommcoachKeepAlive.tsx | 1 - .../CommcoachSettingsView.module.css | 22 +++ 6 files changed, 285 insertions(+), 3 deletions(-) diff --git a/src/pages/views/commcoach/CommcoachDashboardView.module.css b/src/pages/views/commcoach/CommcoachDashboardView.module.css index 54899b2..0cd3f8c 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.module.css +++ b/src/pages/views/commcoach/CommcoachDashboardView.module.css @@ -149,3 +149,90 @@ font-size: 0.9rem; line-height: 1.6; } + +/* ============================================================ */ +/* MOBILE RESPONSIVE */ +/* ============================================================ */ +@media (max-width: 768px) { + .dashboard { + padding: 0.75rem; + } + .kpiGrid { + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + } + .kpiCard { + padding: 0.85rem; + } + .kpiValue { + font-size: 1.5rem; + } + .contextGrid { + grid-template-columns: 1fr; + } + .badgeGrid { + gap: 0.5rem; + } + .badgeCard { + padding: 0.4rem 0.7rem; + font-size: 0.8rem; + } +} + +@media (max-width: 400px) { + .kpiGrid { + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + } + .kpiCard { + padding: 0.65rem; + border-radius: 8px; + } + .kpiValue { + font-size: 1.25rem; + } + .kpiLabel { + font-size: 0.75rem; + } +} + +.newTopicBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.6rem 1.25rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.newTopicBtn:hover { filter: brightness(1.08); } + +@media (max-width: 768px) { + .dashboard { padding: 0.75rem; } + + .kpiGrid { + grid-template-columns: repeat(2, 1fr); + gap: 0.65rem; + } + + .kpiCard { padding: 0.9rem; } + .kpiValue { font-size: 1.5rem; } + .kpiLabel { font-size: 0.78rem; } + .kpiSub { font-size: 0.7rem; } + + .contextGrid { + grid-template-columns: 1fr; + gap: 0.65rem; + } + + .badgeGrid { gap: 0.5rem; } + .badgeCard { padding: 0.4rem 0.65rem; font-size: 0.8rem; } + + .sectionTitle { font-size: 1rem; } + .tipCard { padding: 0.9rem; font-size: 0.85rem; } +} diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx index 72dc546..466d55d 100644 --- a/src/pages/views/commcoach/CommcoachDashboardView.tsx +++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx @@ -25,6 +25,12 @@ export const CommcoachDashboardView: React.FC = () => { } }; + const _handleNewTopic = useCallback(() => { + if (mandateId && instanceId) { + navigate(`/mandates/${mandateId}/commcoach/${instanceId}/coaching?newContext=true`); + } + }, [mandateId, instanceId, navigate]); + const _categoryLabel = useCallback( (category: string) => { const labels: Record = { @@ -88,11 +94,16 @@ export const CommcoachDashboardView: React.FC = () => { {/* Active Contexts */}
-

{t('Aktive Coaching-Themen')}

+
+

{t('Aktive Coaching-Themen')}

+ +
{dashboard.contexts.length === 0 ? (

{t('Noch keine Coaching-Themen angelegt.')}

-

{t('Wechseln Sie zum Tab Coaching, um ein Thema anzulegen.')}

+

{t('Klicken Sie auf "Neues Thema" um zu starten.')}

) : (
diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index 459578d..09b56bb 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -22,6 +22,21 @@ min-width: 36px; } +@media (max-width: 768px) { + .dossierLayout { + flex-direction: column; + height: calc(100vh - var(--mobile-topbar-height, 56px)); + } + + .udbSidebar { + display: none; + } + + .udbSidebarCollapsed { + display: none; + } +} + .udbToggle { position: absolute; top: 8px; @@ -51,9 +66,17 @@ flex-direction: column; flex: 1; min-width: 0; + min-height: 0; overflow: hidden; } +@media (max-width: 768px) { + .dossier { + overflow-y: auto; + overflow-x: hidden; + } +} + /* Context Selector */ .contextSelector { display: flex; @@ -65,6 +88,29 @@ align-items: center; } +@media (max-width: 768px) { + .contextSelector { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0.5rem 0.75rem; + } + .contextSelector::-webkit-scrollbar { display: none; } +} + +@media (max-width: 768px) { + .contextSelector { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0.5rem 0.75rem; + gap: 0.4rem; + scrollbar-width: none; + } + .contextSelector::-webkit-scrollbar { display: none; } +} + .contextChip { display: flex; align-items: center; @@ -166,6 +212,31 @@ flex-shrink: 0; } +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + .headerActions { + flex-wrap: wrap; + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + .headerActions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + .title { font-size: 1.1rem; } +} + .title { font-size: 1.3rem; font-weight: 600; @@ -273,6 +344,36 @@ padding: 0 1rem; } +@media (max-width: 768px) { + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding: 0 0.5rem; + } + .tabs::-webkit-scrollbar { display: none; } + .tab { + white-space: nowrap; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } +} + +@media (max-width: 768px) { + .tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0 0.5rem; + scrollbar-width: none; + } + .tabs::-webkit-scrollbar { display: none; } + .tab { + white-space: nowrap; + padding: 0.5rem 0.9rem; + font-size: 0.8rem; + } +} + .tab { padding: 0.6rem 1.25rem; background: transparent; @@ -325,6 +426,12 @@ .personaSelector { margin-bottom: 1rem; } .personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; } .personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; } + +@media (max-width: 768px) { + .personaGrid { gap: 0.35rem; } + .personaChip { font-size: 0.75rem; padding: 0.3rem 0.6rem; } + .sessionStart { padding: 1rem; } +} .personaChip { display: flex; align-items: center; gap: 0.3rem; padding: 0.4rem 0.8rem; @@ -350,6 +457,17 @@ .sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); } .sessionActions { display: flex; gap: 0.5rem; } +@media (max-width: 768px) { + .sessionHeader { + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + .sessionActions { + flex-wrap: wrap; + } +} + /* Messages */ .messages { flex: 1; @@ -361,9 +479,25 @@ } .message { max-width: 80%; } + +@media (max-width: 768px) { + .message { max-width: 92%; } + .messages { padding: 0.75rem 0.5rem; gap: 0.5rem; } +} .messageUser { align-self: flex-end; } .messageAssistant { align-self: flex-start; } +@media (max-width: 768px) { + .message { max-width: 92%; } + .messages { padding: 0.75rem; gap: 0.5rem; } + .sessionHeader { + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + } + .sessionActions { flex-wrap: wrap; gap: 0.3rem; } +} + .messageBubble { padding: 0.75rem 1rem; border-radius: 12px; @@ -528,6 +662,25 @@ .textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; } +@media (max-width: 768px) { + .inputArea { + padding: 0.5rem 0.5rem calc(env(safe-area-inset-bottom, 0px) + 0.5rem); + } + .textInputRow { + gap: 0.35rem; + } + .sendBtn { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } +} + +@media (max-width: 768px) { + .inputArea { padding: 0.5rem 0.75rem; } + .textInputRow { gap: 0.35rem; } + .sendBtn { padding: 0.5rem 0.75rem; font-size: 0.8rem; } +} + .textInput { flex: 1; min-width: 0; padding: 0.6rem 0.75rem; diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index b4ca96c..24c12a6 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useCommcoach } from '../../../hooks/useCommcoach'; import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { useApiRequest } from '../../../hooks/useApi'; @@ -73,6 +74,7 @@ export const CommcoachDossierView: React.FC = ({ pers const mandateId = persistentMandateId || routeMandateId; const coach = useCommcoach(instanceId); const { request } = useApiRequest(); + const [searchParams, setSearchParams] = useSearchParams(); const [activeTab, setActiveTab] = useState('coaching'); const [showNewContext, setShowNewContext] = useState(false); @@ -144,6 +146,14 @@ export const CommcoachDossierView: React.FC = ({ pers } }, [coach.contexts, coach.selectedContextId, coach.selectContext]); + useEffect(() => { + if (searchParams.get('newContext') === 'true') { + setShowNewContext(true); + searchParams.delete('newContext'); + setSearchParams(searchParams, { replace: true }); + } + }, [searchParams, setSearchParams]); + // Load scores, personas when context changes useEffect(() => { if (!instanceId || !coach.selectedContextId) return; diff --git a/src/pages/views/commcoach/CommcoachKeepAlive.tsx b/src/pages/views/commcoach/CommcoachKeepAlive.tsx index cdf19c5..81f1ae0 100644 --- a/src/pages/views/commcoach/CommcoachKeepAlive.tsx +++ b/src/pages/views/commcoach/CommcoachKeepAlive.tsx @@ -41,7 +41,6 @@ export const CommcoachKeepAlive: React.FC = ({ isVisibl left: 0, right: 0, bottom: 0, - overflow: 'hidden', }} > Date: Thu, 16 Apr 2026 23:13:01 +0200 Subject: [PATCH 06/15] feat db-clean-ui and unified content udm --- src/App.tsx | 3 +- src/api/workflowApi.ts | 2 + .../editor/Automation2FlowEditor.module.css | 10 + .../FlowEditor/editor/FlowCanvas.tsx | 7 + .../FlowEditor/editor/NodeListItem.tsx | 13 +- .../nodes/shared/AiBadge.module.css | 24 + .../FlowEditor/nodes/shared/AiBadge.tsx | 25 + .../FlowEditor/nodes/shared/constants.ts | 2 + src/components/FolderTree/FolderTree.tsx | 32 +- src/components/UnifiedDataBar/FilesTab.tsx | 16 +- src/components/UnifiedDataBar/SourcesTab.tsx | 55 +- .../UnifiedDataBar/UnifiedDataBar.tsx | 25 +- src/config/pageRegistry.tsx | 2 + .../admin/AdminDatabaseHealthPage.module.css | 6 + src/pages/admin/AdminDatabaseHealthPage.tsx | 638 ++++++++++++++++++ src/pages/admin/index.ts | 1 + src/pages/views/workspace/WorkspaceInput.tsx | 111 +-- src/pages/views/workspace/WorkspacePage.tsx | 33 +- 18 files changed, 884 insertions(+), 121 deletions(-) create mode 100644 src/components/FlowEditor/nodes/shared/AiBadge.module.css create mode 100644 src/components/FlowEditor/nodes/shared/AiBadge.tsx create mode 100644 src/pages/admin/AdminDatabaseHealthPage.module.css create mode 100644 src/pages/admin/AdminDatabaseHealthPage.tsx 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..fc102f4 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) && (
@@ -1221,12 +1223,13 @@ interface _MandateGroupViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; } const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, + expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, }) => { const [hovered, setHovered] = useState(false); const chevron = group.expanded ? '\u25BE' : '\u25B8'; @@ -1270,6 +1273,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ expandedParentGroups={expandedParentGroups} loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} + onSendToChat={onSendToChat} /> ))}
@@ -1293,12 +1297,13 @@ interface _FeatureNodeViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; } const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ node, onToggle, onAddTable, isTableAdded, addingKey, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, + expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1333,6 +1338,27 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} + {hovered && onSendToChat && ( + + )}
{node.expanded && node.tables && node.tables.length > 0 && ( @@ -1375,6 +1401,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ onAdd={onAddTable} isAdded={isTableAdded(node.featureInstanceId, table.tableName)} isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + onSendToChat={onSendToChat} /> ))}
@@ -1397,10 +1424,11 @@ interface _FeatureTableRowProps { onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isAdded: boolean; isAdding: boolean; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, onAdd, isAdded, isAdding, + featureNode, table, onAdd, isAdded, isAdding, onSendToChat, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1423,6 +1451,25 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ {tableLabel} + {hovered && onSendToChat && ( + + )} {hovered && !isAdded && (
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/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/index.ts b/src/pages/admin/index.ts index dc67667..74bc916 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -18,3 +18,4 @@ export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; export { AdminLogsPage } from './AdminLogsPage'; export { AdminLanguagesPage } from './AdminLanguagesPage'; export { AdminDemoConfigPage } from './AdminDemoConfigPage'; +export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage'; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9cc49ba..4e15428 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -545,116 +545,7 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins {uploading ? '...' : '+'} - {(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 _leftPanelBody = ( = ({ persistentInstance onDeleteChat={_handleDeleteChat} onFileSelect={_handleFileSelect} onSourcesChanged={_handleSourcesChanged} + onSendToChat_Files={_handleSendToChat_Files} + onSendToChat_FeatureSource={_handleSendToChat_FeatureSource} /> ); From 4c959538ac6209c6a3f05e4a9f31418c1cb999d9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Fri, 17 Apr 2026 11:50:25 +0200 Subject: [PATCH 07/15] testing fixes, udb source handling fixes --- src/components/FolderTree/FolderTree.tsx | 2 +- src/components/Navigation/UserSection.tsx | 4 +- .../LanguageSelector.module.css | 31 + .../LanguageSelector/LanguageSelector.tsx | 29 + .../UiComponents/LanguageSelector/index.ts | 2 + src/components/UiComponents/Popup/Popup.tsx | 10 +- src/components/UnifiedDataBar/SourcesTab.tsx | 1109 ++++++++++++----- .../UnifiedDataBar/UnifiedDataBar.tsx | 3 + src/components/UnifiedDataBar/index.ts | 2 +- src/pages/ComplianceAuditPage.tsx | 4 +- src/pages/Login.tsx | 5 +- src/pages/PasswordResetRequest.tsx | 4 + src/pages/Register.tsx | 8 +- src/pages/Reset.tsx | 7 + src/pages/Store.tsx | 4 +- src/pages/admin/AdminFeatureAccessPage.tsx | 62 +- .../admin/AdminFeatureInstanceUsersPage.tsx | 8 +- src/pages/admin/AdminFeatureRolesPage.tsx | 12 +- src/pages/admin/AdminInvitationsPage.tsx | 8 +- src/pages/admin/AdminMandateRolesPage.tsx | 8 +- src/pages/admin/AdminMandatesPage.tsx | 37 +- src/pages/admin/AdminUserMandatesPage.tsx | 8 +- src/pages/admin/AdminUsersPage.tsx | 8 +- src/pages/admin/InstanceDetailModal.tsx | 8 +- src/pages/basedata/ConnectionsPage.tsx | 4 +- src/pages/basedata/FilesPage.tsx | 4 +- src/pages/basedata/PromptsPage.tsx | 8 +- .../trustee/TrusteePositionDocumentsView.tsx | 4 +- src/pages/views/workspace/WorkspaceInput.tsx | 50 +- src/pages/views/workspace/WorkspacePage.tsx | 29 + 30 files changed, 1039 insertions(+), 443 deletions(-) create mode 100644 src/components/UiComponents/LanguageSelector/LanguageSelector.module.css create mode 100644 src/components/UiComponents/LanguageSelector/LanguageSelector.tsx create mode 100644 src/components/UiComponents/LanguageSelector/index.ts diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index fc102f4..2e2194c 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -620,7 +620,7 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, - onScopeChange, onNeutralizeToggle, + onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat, }: FolderTreeProps) { const { t } = useLanguage(); diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 384f930..842a353 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -140,8 +140,8 @@ export const UserSection: React.FC = () => { {/* Legal Modal */} {showLegalModal && ( -
setShowLegalModal(false)}> -
e.stopPropagation()}> +
+

{t('Legal notices')}

+ ); +} + +export default LanguageSelector; diff --git a/src/components/UiComponents/LanguageSelector/index.ts b/src/components/UiComponents/LanguageSelector/index.ts new file mode 100644 index 0000000..9952ab3 --- /dev/null +++ b/src/components/UiComponents/LanguageSelector/index.ts @@ -0,0 +1,2 @@ +export { LanguageSelector } from './LanguageSelector'; +export { default } from './LanguageSelector'; diff --git a/src/components/UiComponents/Popup/Popup.tsx b/src/components/UiComponents/Popup/Popup.tsx index fbdbe7f..c19894b 100644 --- a/src/components/UiComponents/Popup/Popup.tsx +++ b/src/components/UiComponents/Popup/Popup.tsx @@ -23,6 +23,8 @@ export interface PopupProps { className?: string; size?: 'small' | 'medium' | 'large' | 'fullscreen'; closable?: boolean; + closeOnBackdropClick?: boolean; + closeOnEscape?: boolean; actions?: PopupAction[]; } @@ -36,6 +38,8 @@ export function Popup({ className = '', size = 'medium', closable = true, + closeOnBackdropClick = false, + closeOnEscape = true, actions = [] }: PopupProps) { const { t } = useLanguage(); @@ -43,7 +47,7 @@ export function Popup({ // Handle escape key React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && closable) { + if (e.key === 'Escape' && closable && closeOnEscape) { onClose(); } }; @@ -58,13 +62,13 @@ export function Popup({ document.removeEventListener('keydown', handleEscape); document.body.style.overflow = 'unset'; }; - }, [isOpen, closable, onClose]); + }, [isOpen, closable, closeOnEscape, onClose]); if (!isOpen) return null; // Handle backdrop click const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget && closable) { + if (e.target === e.currentTarget && closable && closeOnBackdropClick) { onClose(); } }; diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 8e9da49..7580ec0 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -44,6 +44,7 @@ interface UdbFeatureDataSource { label: string; scope: string; neutralize: boolean; + neutralizeFields?: string[]; recordFilter?: Record; } @@ -106,7 +107,8 @@ interface ParentRecordNode { interface SourcesTabProps { context: UdbContext; onSourcesChanged?: () => void; - onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; + onSendToChat_FeatureSource?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; + onAttachDataSource?: (dsId: string) => void; } /* ─── Icons ──────────────────────────────────────────────────────────── */ @@ -153,28 +155,6 @@ function _getSourceColor(sourceType: string): string { return _SOURCE_COLORS[sourceType] || '#F25843'; } -const _SOURCE_ICONS: Record = { - sharepointFolder: '\uD83D\uDCC1', - sharepoint: '\uD83D\uDCC1', - onedriveFolder: '\u2601\uFE0F', - onedrive: '\u2601\uFE0F', - outlookFolder: '\uD83D\uDCE7', - outlook: '\uD83D\uDCE7', - googleDriveFolder: '\uD83D\uDCC2', - drive: '\uD83D\uDCC2', - gmailFolder: '\uD83D\uDCE8', - gmail: '\uD83D\uDCE8', - ftpFolder: '\uD83D\uDD17', - files: '\uD83D\uDD17', - 'local:ftp': '\uD83D\uDD17', - 'local:jira': '\uD83D\uDD27', - clickup: '\uD83D\uDCCB', -}; - -function _getSourceIcon(sourceType: string): string { - return _SOURCE_ICONS[sourceType] || '\uD83D\uDCC1'; -} - /* ─── Scope / Neutralize constants ───────────────────────────────────── */ const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; @@ -224,38 +204,19 @@ function _mapFeatureTreeUpdate( })); } -function _findFeatureInstanceMeta( +function _findTableFields( groups: MandateGroupNode[], featureInstanceId: string, -): { mandateLabel: string; instanceLabel: string } | null { + tableName: string, +): string[] { for (const g of groups) { const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); - if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; + if (fc?.tables) { + const tbl = fc.tables.find(t => t.tableName === tableName); + if (tbl) return tbl.fields; + } } - return null; -} - -function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string { - const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; - return pathPart ? `${connLabel} / ${pathPart}` : connLabel; -} - -function _featureDataSourceHoverTitle( - meta: { mandateLabel: string; instanceLabel: string } | null, - fds: UdbFeatureDataSource, -): string { - const parts: string[] = []; - if (meta) { - parts.push(meta.mandateLabel, meta.instanceLabel); - } - const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName - ? `${fds.label} (${fds.tableName})` - : (fds.label || fds.tableName); - parts.push(labelPart); - if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { - parts.push(fds.objectKey); - } - return parts.join(' / '); + return []; } /* ─── Data fetching (module-level) ───────────────────────────────────── */ @@ -340,7 +301,7 @@ function _Spinner(): React.ReactElement { /* ─── Component ──────────────────────────────────────────────────────── */ -const SourcesTab: React.FC = ({ context, onSourcesChanged, onSendToChat_FeatureSource }) => { +const SourcesTab: React.FC = ({ context, onSourcesChanged, onSendToChat_FeatureSource, onAttachDataSource }) => { const { t } = useLanguage(); const _scopeLabel = (scope: string) => ({ personal: t('Persönlich'), @@ -366,6 +327,43 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe const [loadingFeatures, setLoadingFeatures] = useState(false); const [addingFeatureKey, setAddingFeatureKey] = useState(null); + /* ── Multi-selection state for Browse-Tree ── */ + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const lastClickedKeyRef = useRef(null); + + const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => { + const result: string[] = []; + for (const n of nodes) { + result.push(n.key); + if (n.expanded && n.children) { + result.push(..._flattenVisibleKeys(n.children)); + } + } + return result; + }, []); + + const _handleNodeSelect = useCallback((node: TreeNode, e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + setSelectedKeys(prev => { + const next = new Set(prev); + if (next.has(node.key)) next.delete(node.key); else next.add(node.key); + return next; + }); + lastClickedKeyRef.current = node.key; + } else if (e.shiftKey && lastClickedKeyRef.current) { + const visible = _flattenVisibleKeys(tree); + const a = visible.indexOf(lastClickedKeyRef.current); + const b = visible.indexOf(node.key); + if (a !== -1 && b !== -1) { + const [start, end] = a < b ? [a, b] : [b, a]; + setSelectedKeys(new Set(visible.slice(start, end + 1))); + } + } else { + setSelectedKeys(new Set([node.key])); + lastClickedKeyRef.current = node.key; + } + }, [tree, _flattenVisibleKeys]); + const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); @@ -405,6 +403,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe label: d.label, scope: d.scope || 'personal', neutralize: d.neutralize ?? false, + neutralizeFields: d.neutralizeFields || undefined, recordFilter: d.recordFilter || undefined, })); setFeatureDataSources(list); @@ -489,21 +488,26 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe }, [instanceId, _updateNode]); /* ── Add as DataSource ── */ - const _addAsDataSource = useCallback(async (node: TreeNode) => { - if (!node.service || !node.connectionId) return; + const _addAsDataSource = useCallback(async (node: TreeNode): Promise => { + if (!node.connectionId) return null; + const sourceType = node.service + ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) + : (node.authority || node.type); setAddingPath(node.key); try { - await api.post(`/api/workspace/${instanceId}/datasources`, { + const res = await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: node.connectionId, - sourceType: _SERVICE_TO_SOURCE_TYPE[node.service] || node.service, + 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); } @@ -530,6 +534,38 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe ); }, [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); @@ -574,6 +610,21 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } }, []); + /* ── 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; @@ -811,68 +862,6 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe return (
- {/* ── Active Personal Sources ── */} - {dataSources.length > 0 && ( -
-
- {t('Aktive persönliche Quellen')} -
- {[...dataSources].sort((a, b) => { - const aKey = `${a.sourceType}|${a.label || a.path || ''}`; - const bKey = `${b.sourceType}|${b.label || b.path || ''}`; - return aKey.localeCompare(bKey); - }).map(ds => { - const connColor = _getSourceColor(ds.sourceType); - const connNode = tree.find(n => n.connectionId === ds.connectionId); - const connLabel = connNode?.label || ds.connectionId; - const folder = ds.label || ds.path || ds.id; - return ( -
- {_getSourceIcon(ds.sourceType)} - - {connLabel} – {folder} - - - - -
- ); - })} -
-
- )} - {/* ── Browse Sources header ── */}
@@ -909,149 +898,20 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe onAdd={_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 ── */}
@@ -1096,6 +956,12 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} onSendToChat={onSendToChat_FeatureSource} + featureDataSources={featureDataSources} + onCycleScope={_cycleFeatureScope} + onToggleNeutralize={_toggleFeatureNeutralize} + onToggleNeutralizeField={_toggleNeutralizeField} + onRemoveFds={_removeFeatureDataSource} + featureTree={featureTree} /> ))}
@@ -1104,6 +970,15 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe /* ─── 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; @@ -1111,10 +986,22 @@ interface _TreeNodeViewProps { onAdd: (node: TreeNode) => void; 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, + dataSources, onCycleScope, onToggleNeutralize, onRemoveDs, onSendToChat, scopeCycleTitle, + selectedKeys, onSelect, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1122,16 +1009,60 @@ 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 ds = _findDs(dataSources, node); + const alreadyAdded = isAdded(node.connectionId, node.service, node.path); const isAdding = addingPath === node.key; + 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', @@ -1142,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', }} @@ -1155,11 +1092,78 @@ 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 → clickable, inherited → dimmed static */} + {ds ? ( + + ) : ( + + {_SCOPE_ICONS[effectiveScope || 'personal']} + + )} + + {/* Neutralize: own DS → clickable, inherited → dimmed static */} + {ds ? ( + + ) : ( + + {'\uD83D\uDD12'} + + )} + + {/* Remove: only when DS exists */} + {ds && ( + + )} + + {/* Add button: on hover when not yet added */} + {hovered && !alreadyAdded && !ds && ( )} - {canAdd && alreadyAdded && ( - - {'\u2713'} - - )}
{node.expanded && node.children && node.children.length > 0 && ( @@ -1193,6 +1192,16 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ onAdd={onAdd} isAdded={isAdded} addingPath={addingPath} + dataSources={dataSources} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onRemoveDs={onRemoveDs} + onSendToChat={onSendToChat} + scopeCycleTitle={scopeCycleTitle} + selectedKeys={selectedKeys} + onSelect={onSelect} + inheritedScope={childInheritedScope} + inheritedNeutralize={childInheritedNeutralize} /> ))}
@@ -1209,7 +1218,16 @@ 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; @@ -1223,13 +1241,15 @@ interface _MandateGroupViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; + 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, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, + featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + onRemoveFds, featureTree, }) => { const [hovered, setHovered] = useState(false); const chevron = group.expanded ? '\u25BE' : '\u25B8'; @@ -1274,6 +1294,12 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ loadingParentGroup={loadingParentGroup} addingParentKey={addingParentKey} onSendToChat={onSendToChat} + featureDataSources={featureDataSources} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onToggleNeutralizeField={onToggleNeutralizeField} + onRemoveFds={onRemoveFds} + featureTree={featureTree} /> ))}
@@ -1284,7 +1310,7 @@ 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; @@ -1297,18 +1323,24 @@ interface _FeatureNodeViewProps { expandedParentGroups: Set; loadingParentGroup: string | null; addingParentKey: string | null; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; + 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, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, + featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + onRemoveFds, featureTree, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; + const wildcardFds = featureDataSources.find( + f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, + ); + const parentTables = (node.tables || []).filter(t => t.isParent); const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable); @@ -1318,11 +1350,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', }} > @@ -1338,11 +1386,12 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} - {hovered && onSendToChat && ( + + {(wildcardFds || hovered) && ( )} + + {wildcardFds && ( + + )} + {wildcardFds && ( + + )} + {wildcardFds && ( + + )} + + {!wildcardFds && hovered && ( + + )}
{node.expanded && node.tables && node.tables.length > 0 && ( @@ -1388,22 +1488,44 @@ 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}`} - onSendToChat={onSendToChat} - /> - ))} + {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} + onAdd={onAddTable} + isAdded={isTableAdded(node.featureInstanceId, table.tableName)} + isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + onSendToChat={onSendToChat} + fds={fds} + onCycleScope={onCycleScope} + onToggleNeutralize={onToggleNeutralize} + onToggleNeutralizeField={onToggleNeutralizeField} + onRemoveFds={onRemoveFds} + featureTree={featureTree} + inheritedScope={wildcardFds?.scope} + inheritedNeutralize={wildcardFds?.neutralize} + /> + ); + })}
)} @@ -1424,70 +1546,274 @@ interface _FeatureTableRowProps { onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; isAdded: boolean; isAdding: boolean; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string }) => void; + 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, 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')}) + )} + + + {(fds || hovered) && ( + + )} + + {fds && onCycleScope && ( + + )} + {fds && onToggleNeutralize && ( + + )} + {fds && onRemoveFds && ( + + )} + + {/* Inherited scope/neutralize indicators (no own FDS) */} + {!fds && effectiveScope && ( + + {_SCOPE_ICONS[effectiveScope] || _SCOPE_ICONS.personal} + + )} + {!fds && effectiveNeutralize && ( + + {'\uD83D\uDD12'} + + )} + + {!fds && hovered && !isAdded && ( + + )} +
+ + {/* 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 && onSendToChat && ( + + {(fds || hovered) && ( )} - {hovered && !isAdded && ( + + {fds && onToggleNeutralizeField && ( )} - {isAdded && ( - - {'\u2713'} + + {inheritedScope && ( + + {_SCOPE_ICONS[inheritedScope] || _SCOPE_ICONS.personal} )}
@@ -1496,7 +1822,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ /* ─── ParentGroupView (parent table → parent records) ────────────────── */ -interface _ParentGroupViewProps { +interface _ParentGroupViewProps extends _FdsActionProps { featureNode: FeatureConnectionNode; parentTable: FeatureTableNode; label: string; @@ -1510,11 +1836,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); @@ -1550,19 +1882,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} + /> + ); + })}
)} @@ -1586,11 +1931,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, + featureNode, record, childTables, allTables: _allTables, onToggle, onAdd, isAdded, isAdding, + onSendToChat, fds, onCycleScope, onToggleNeutralize, onRemoveFds, + inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); const [hovered, setHovered] = useState(false); @@ -1602,11 +1956,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(', ')} @@ -1615,10 +1984,85 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ {chevron} {'\uD83D\uDCCB'} - + {record.displayLabel} - {hovered && !isAdded && ( + + {/* Chat-Senden: always visible when fds, hover-only otherwise */} + {(fds || hovered) && ( + + )} + + {/* FDS inline actions */} + {fds && onCycleScope && ( + + )} + {fds && onToggleNeutralize && ( + + )} + {fds && onRemoveFds && ( + + )} + + {/* Inherited scope/neutralize indicators */} + {!fds && inheritedScope && ( + + {_SCOPE_ICONS[inheritedScope] || _SCOPE_ICONS.personal} + + )} + {!fds && (inheritedNeutralize ?? false) && ( + + {'\uD83D\uDD12'} + + )} + + {/* Add button (only when not yet added) */} + {!fds && hovered && !isAdded && ( )} - {isAdded && ( - - {'\u2713'} - - )}
{record.expanded && ( diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 0e94c98..b872405 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -43,6 +43,7 @@ interface UnifiedDataBarProps { onSourcesChanged?: () => void; onSendToChat_Files?: (items: AddToChat_FileItem[]) => void; onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void; + onAttachDataSource?: (dsId: string) => void; className?: string; } @@ -70,6 +71,7 @@ const UnifiedDataBar: React.FC = ({ onSourcesChanged, onSendToChat_Files, onSendToChat_FeatureSource, + onAttachDataSource, className, }) => { const { t } = useLanguage(); @@ -121,6 +123,7 @@ const UnifiedDataBar: React.FC = ({ context={context} onSourcesChanged={onSourcesChanged} onSendToChat_FeatureSource={onSendToChat_FeatureSource} + onAttachDataSource={onAttachDataSource} /> )}
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/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/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 2fa7740..c733f11 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -230,8 +230,8 @@ export const PromptsPage: React.FC = () => { {/* Create Modal */} {showCreateModal && ( -
setShowCreateModal(false)}> -
e.stopPropagation()}> +
+

{t('Neuer Prompt')}

- {/* Scope: own DS → clickable, inherited → dimmed static */} - {ds ? ( - - ) : ( - - {_SCOPE_ICONS[effectiveScope || 'personal']} - - )} + {/* Scope: own DS → cycle, no DS → create DS then cycle */} + - {/* Neutralize: own DS → clickable, inherited → dimmed static */} - {ds ? ( - - ) : ( - - {'\uD83D\uDD12'} - - )} + {/* Neutralize: own DS → toggle, no DS → create DS then toggle */} + {/* Remove: only when DS exists */} {ds && ( @@ -1162,23 +1158,6 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ )} - {/* Add button: on hover when not yet added */} - {hovered && !alreadyAdded && !ds && ( - - )}
{node.expanded && node.children && node.children.length > 0 && ( @@ -1189,7 +1168,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ node={child} depth={depth + 1} onToggle={onToggle} - onAdd={onAdd} + onEnsureDs={onEnsureDs} isAdded={isAdded} addingPath={addingPath} dataSources={dataSources} @@ -1231,7 +1210,7 @@ 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; @@ -1245,7 +1224,7 @@ interface _MandateGroupViewProps extends _FdsActionProps { } const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ - group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, + group, onToggleGroup, onToggleFeature, onEnsureFds, isTableAdded, addingKey, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, @@ -1283,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} @@ -1313,7 +1292,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ 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; @@ -1327,7 +1306,7 @@ interface _FeatureNodeViewProps extends _FdsActionProps { } const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ - node, onToggle, onAddTable, isTableAdded, addingKey, + node, onToggle, onEnsureFds, onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, @@ -1387,46 +1366,60 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} - {(wildcardFds || hovered) && ( - - )} + {/* Chat: always visible */} + - {wildcardFds && ( - - )} - {wildcardFds && ( - - )} + {/* 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 && ( )} - {!wildcardFds && hovered && ( - - )}
{node.expanded && node.tables && node.tables.length > 0 && ( @@ -1511,9 +1482,7 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ key={table.objectKey} featureNode={node} table={table} - onAdd={onAddTable} - isAdded={isTableAdded(node.featureInstanceId, table.tableName)} - isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + onEnsureFds={onEnsureFds} onSendToChat={onSendToChat} fds={fds} onCycleScope={onCycleScope} @@ -1543,9 +1512,7 @@ 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; @@ -1558,7 +1525,7 @@ interface _FeatureTableRowProps { } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, onAdd, isAdded, isAdding, onSendToChat, + featureNode, table, onEnsureFds, onSendToChat, fds, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, onRemoveFds, featureTree, inheritedScope, inheritedNeutralize, }) => { @@ -1620,38 +1587,56 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ )} - {(fds || hovered) && ( - - )} + {/* Chat: always visible */} + - {fds && onCycleScope && ( - - )} - {fds && onToggleNeutralize && ( - - )} + {/* Scope: own FDS → cycle, otherwise create then cycle */} + + + {/* Neutralize: own FDS → toggle, otherwise create then toggle */} + + + {/* Remove: only when FDS exists */} {fds && onRemoveFds && ( )} - {/* Inherited scope/neutralize indicators (no own FDS) */} - {!fds && effectiveScope && ( - - {_SCOPE_ICONS[effectiveScope] || _SCOPE_ICONS.personal} - - )} - {!fds && effectiveNeutralize && ( - - {'\uD83D\uDD12'} - - )} - - {!fds && hovered && !isAdded && ( - - )}
{/* Expandable field sub-nodes */} @@ -1780,21 +1732,21 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ {fieldName} - {(fds || hovered) && ( - - )} + {/* Chat: always visible */} + - {fds && onToggleNeutralizeField && ( + {/* Neutralize: own FDS → clickable, otherwise dimmed */} + {fds && onToggleNeutralizeField ? ( - )} - - {inheritedScope && ( + ) : ( - {_SCOPE_ICONS[inheritedScope] || _SCOPE_ICONS.personal} + {'\uD83D\uDD12'} )} + + {/* Scope: inherited indicator */} + + {_SCOPE_ICONS[inheritedScope || 'personal']} +
); }; @@ -1942,7 +1900,7 @@ interface _ParentRecordRowProps { const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ featureNode, record, childTables, allTables: _allTables, - onToggle, onAdd, isAdded, isAdding, + onToggle, onSendToChat, fds, onCycleScope, onToggleNeutralize, onRemoveFds, inheritedScope, inheritedNeutralize, }) => { @@ -1991,31 +1949,29 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ {record.displayLabel} - {/* Chat-Senden: always visible when fds, hover-only otherwise */} - {(fds || hovered) && ( - - )} + {/* Chat: always visible */} + - {/* FDS inline actions */} - {fds && onCycleScope && ( + {/* Scope: own FDS → clickable, otherwise dimmed */} + {fds && onCycleScope ? ( + ) : ( + + {_SCOPE_ICONS[inheritedScope || 'personal']} + )} - {fds && onToggleNeutralize && ( + + {/* Neutralize: own FDS → clickable, otherwise dimmed */} + {fds && onToggleNeutralize ? ( + ) : ( + + {'\uD83D\uDD12'} + )} + + {/* Remove: only when FDS exists */} {fds && onRemoveFds && ( )} - {/* Inherited scope/neutralize indicators */} - {!fds && inheritedScope && ( - - {_SCOPE_ICONS[inheritedScope] || _SCOPE_ICONS.personal} - - )} - {!fds && (inheritedNeutralize ?? false) && ( - - {'\uD83D\uDD12'} - - )} - - {/* Add button (only when not yet added) */} - {!fds && hovered && !isAdded && ( - - )}
{record.expanded && ( From 238dd6ae1603910d13777ac8b9620558c1590a3d Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 13:54:00 +0200 Subject: [PATCH 09/15] bugfix(CON-01, CON-02) --- src/hooks/useConnections.ts | 27 ++++++++++++++++++++++--- src/pages/basedata/ConnectionsPage.tsx | 28 +++++++++++++++++--------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index d5ea099..563f7a1 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -333,6 +333,8 @@ export function useConnections() { // Create Google connection and open OAuth popup const createGoogleConnectionAndAuth = async (): Promise => { + if (isConnecting) return; + setIsConnecting(true); try { // Step 1: Create a Google connection const newConnection = await createConnection({ @@ -354,7 +356,7 @@ export function useConnections() { authUrl = `${apiBaseUrl}${authUrl}`; } - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const popup = window.open( authUrl, 'google-connection', @@ -362,6 +364,7 @@ export function useConnections() { ); if (!popup) { + setIsConnecting(false); reject(new Error('Popup was blocked. Please allow popups and try again.')); return; } @@ -371,6 +374,7 @@ export function useConnections() { if (popup.closed) { clearInterval(checkClosed); window.removeEventListener('message', messageListener); + setIsConnecting(false); console.log('Google OAuth popup closed'); // Refresh connections in case it succeeded fetchConnections(); @@ -390,6 +394,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); console.log('Google connection successful'); // Refresh connections fetchConnections(); @@ -398,6 +403,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); reject(new Error(event.data.error || 'Google connection failed')); } }; @@ -405,6 +411,7 @@ export function useConnections() { window.addEventListener('message', messageListener); }); } catch (error) { + setIsConnecting(false); console.error('Error creating Google connection:', error); throw error; } @@ -412,6 +419,8 @@ export function useConnections() { // Create ClickUp connection and open OAuth popup const createClickupConnectionAndAuth = async (): Promise => { + if (isConnecting) return; + setIsConnecting(true); try { const newConnection = await createConnection({ type: 'clickup', @@ -430,7 +439,7 @@ export function useConnections() { authUrl = `${apiBaseUrl}${authUrl}`; } - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const popup = window.open( authUrl, 'clickup-connection', @@ -438,6 +447,7 @@ export function useConnections() { ); if (!popup) { + setIsConnecting(false); reject(new Error('Popup was blocked. Please allow popups and try again.')); return; } @@ -446,6 +456,7 @@ export function useConnections() { if (popup.closed) { clearInterval(checkClosed); window.removeEventListener('message', messageListener); + setIsConnecting(false); console.log('ClickUp OAuth popup closed'); fetchConnections(); resolve(); @@ -462,6 +473,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); console.log('ClickUp connection successful'); fetchConnections(); resolve(); @@ -469,6 +481,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); reject(new Error(event.data.error || 'ClickUp connection failed')); } }; @@ -476,6 +489,7 @@ export function useConnections() { window.addEventListener('message', messageListener); }); } catch (error) { + setIsConnecting(false); console.error('Error creating ClickUp connection:', error); throw error; } @@ -483,6 +497,8 @@ export function useConnections() { // Create Microsoft connection and open OAuth popup const createMicrosoftConnectionAndAuth = async (): Promise => { + if (isConnecting) return; + setIsConnecting(true); try { // Step 1: Create a Microsoft connection const newConnection = await createConnection({ @@ -504,7 +520,7 @@ export function useConnections() { authUrl = `${apiBaseUrl}${authUrl}`; } - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const popup = window.open( authUrl, 'msft-connection', @@ -512,6 +528,7 @@ export function useConnections() { ); if (!popup) { + setIsConnecting(false); reject(new Error('Popup was blocked. Please allow popups and try again.')); return; } @@ -521,6 +538,7 @@ export function useConnections() { if (popup.closed) { clearInterval(checkClosed); window.removeEventListener('message', messageListener); + setIsConnecting(false); console.log('Microsoft OAuth popup closed'); // Refresh connections in case it succeeded fetchConnections(); @@ -540,6 +558,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); console.log('Microsoft connection successful'); // Refresh connections fetchConnections(); @@ -548,6 +567,7 @@ export function useConnections() { clearInterval(checkClosed); window.removeEventListener('message', messageListener); popup.close(); + setIsConnecting(false); reject(new Error(event.data.error || 'Microsoft connection failed')); } }; @@ -555,6 +575,7 @@ export function useConnections() { window.addEventListener('message', messageListener); }); } catch (error) { + setIsConnecting(false); console.error('Error creating Microsoft connection:', error); throw error; } diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index b76aef8..a8967bc 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -97,12 +97,15 @@ export const ConnectionsPage: React.FC = () => { // Handle edit submit const handleEditSubmit = async (data: Partial) => { if (!editingConnection) return; - // Note: updateConnection is handled through the hook try { - // Ensure authority is properly typed - filter and validate authority value const updateData: Partial = { ...data }; - - // Validate and set authority if present + + // Strip computed/read-only fields the backend cannot write. + delete (updateData as any).connectionReference; + delete (updateData as any).displayLabel; + delete (updateData as any).tokenStatus; + delete (updateData as any).tokenExpiresAt; + if (data.authority) { if ( data.authority === 'local' || @@ -112,7 +115,6 @@ export const ConnectionsPage: React.FC = () => { ) { updateData.authority = data.authority; } else { - // Remove invalid authority value delete (updateData as any).authority; } } @@ -173,8 +175,10 @@ export const ConnectionsPage: React.FC = () => { } }; - // Handle create Google connection + // Guards prevent double-trigger while the OAuth popup is open, which would + // otherwise create additional orphan PENDING connections on every click. const handleCreateGoogle = async () => { + if (isConnecting) return; try { await createGoogleConnectionAndAuth(); refetch(); @@ -183,8 +187,8 @@ export const ConnectionsPage: React.FC = () => { } }; - // Handle create Microsoft connection const handleCreateMicrosoft = async () => { + if (isConnecting) return; try { await createMicrosoftConnectionAndAuth(); refetch(); @@ -194,6 +198,7 @@ export const ConnectionsPage: React.FC = () => { }; const handleCreateClickup = async () => { + if (isConnecting) return; try { await createClickupConnectionAndAuth(); refetch(); @@ -222,7 +227,12 @@ export const ConnectionsPage: React.FC = () => { // Form attributes for edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked']; + const excludedFields = [ + 'id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', + 'connectedAt', 'lastChecked', + // computed/read-only fields the backend rejects on write + 'connectionReference', 'displayLabel', 'tokenStatus', 'tokenExpiresAt', + ]; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); @@ -255,7 +265,7 @@ export const ConnectionsPage: React.FC = () => { className={styles.secondaryButton} onClick={handleAdminConsent} disabled={adminConsentPending} - title={t('Microsoft Admin-Zustimmung erteilt der')} + title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')} > {t('Admin-Zustimmung')} From ab256bb09446dda14af0f2427612f7436e70c4dd Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 14:10:18 +0200 Subject: [PATCH 10/15] bugfix(FIL-01 + files verschwunden nach hochladen und neuladen --- src/hooks/useFiles.ts | 10 +++++++++- src/pages/basedata/FilesPage.tsx | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 305c3d1..04306e8 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -480,7 +480,12 @@ export function useFileOperations() { * - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - Upload should now work correctly */ - const handleFileUpload = async (file: globalThis.File, workflowId?: string, featureInstanceId?: string) => { + const handleFileUpload = async ( + file: globalThis.File, + workflowId?: string, + featureInstanceId?: string, + folderId?: string | null, + ) => { setUploadError(null); setUploadingFile(true); @@ -504,6 +509,9 @@ export function useFileOperations() { if (featureInstanceId) { formData.append('featureInstanceId', featureInstanceId); } + if (folderId) { + formData.append('folderId', folderId); + } // FormData is now correctly configured for backend diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 7a6d4d8..60a4295 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -293,7 +293,7 @@ export const FilesPage: React.FC = () => { let successCount = 0; let errorCount = 0; for (const file of Array.from(picked)) { - const result = await handleFileUpload(file); + const result = await handleFileUpload(file, undefined, undefined, selectedFolderId); if (result?.success) successCount++; else errorCount++; } if (fileInputRef.current) fileInputRef.current.value = ''; From 82844f0cbe010629f6d43bdbe18842bab4585244 Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 14:33:19 +0200 Subject: [PATCH 11/15] =?UTF-8?q?bugfix(FIL-02+allgemein=20previewer=20wie?= =?UTF-8?q?der=20hergestellt=20und=20content=20preview=20f=C3=BCr=20word?= =?UTF-8?q?=20und=20excel=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 308 +++++++++++++++ package.json | 3 + .../ContentPreview/ContentPreview.module.css | 118 ++++++ .../ContentPreview/ContentPreview.tsx | 357 ++++++++--------- .../renderers/ExcelRenderer.tsx | 281 ++++++++++++++ .../ContentPreview/renderers/WordRenderer.tsx | 110 ++++++ .../ContentPreview/renderers/index.ts | 2 + src/contexts/FileContext.tsx | 6 +- src/hooks/useFiles.ts | 364 +++--------------- src/pages/basedata/FilesPage.tsx | 27 +- 10 files changed, 1046 insertions(+), 530 deletions(-) create mode 100644 src/components/ContentPreview/renderers/ExcelRenderer.tsx create mode 100644 src/components/ContentPreview/renderers/WordRenderer.tsx diff --git a/package-lock.json b/package-lock.json index 7cfcea4..4f48a98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", "axios": "^1.8.3", + "docx-preview": "^0.3.7", "dotenv": "^16.0.3", "express": "^4.18.2", "framer-motion": "^12.7.3", @@ -22,6 +23,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", + "mammoth": "^1.12.0", "motion": "^12.7.3", "pg": "^8.8.0", "proj4": "^2.20.2", @@ -34,6 +36,7 @@ "react-router-dom": "^7.7.1", "recharts": "^3.7.0", "remark-gfm": "^4.0.1", + "xlsx": "^0.18.5", "xstate": "^5.20.1" }, "devDependencies": { @@ -1980,6 +1983,14 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xstate/react": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@xstate/react/-/react-5.0.5.tgz", @@ -2035,6 +2046,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2131,6 +2150,30 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -2337,6 +2380,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2415,6 +2470,14 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2541,6 +2604,22 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2794,6 +2873,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==" + }, + "node_modules/docx-preview": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", + "dependencies": { + "jszip": ">=3.0.0" + } + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -2895,6 +2987,14 @@ "node": ">=12" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3608,6 +3708,14 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/framer-motion": { "version": "12.23.9", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz", @@ -3957,6 +4065,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -4111,6 +4224,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4258,6 +4376,17 @@ "node": ">=10" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -4318,6 +4447,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4405,6 +4542,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -4425,6 +4572,37 @@ "yallist": "^3.0.2" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5571,6 +5749,11 @@ "node": ">= 0.8" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5621,6 +5804,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -5700,6 +5888,14 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5910,6 +6106,11 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/proj4": { "version": "2.20.2", "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.20.2.tgz", @@ -6206,6 +6407,25 @@ "node": ">=18" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/recharts": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", @@ -6522,6 +6742,11 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6673,6 +6898,22 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -6687,6 +6928,19 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6901,6 +7155,11 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==" + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", @@ -7078,6 +7337,11 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -7251,6 +7515,22 @@ "integrity": "sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==", "license": "MIT" }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7261,6 +7541,34 @@ "node": ">=0.10.0" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xstate": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz", diff --git a/package.json b/package.json index 456aebc..e94b6ae 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/leaflet": "^1.9.21", "@xstate/react": "^5.0.0", "axios": "^1.8.3", + "docx-preview": "^0.3.7", "dotenv": "^16.0.3", "express": "^4.18.2", "framer-motion": "^12.7.3", @@ -28,6 +29,7 @@ "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", + "mammoth": "^1.12.0", "motion": "^12.7.3", "pg": "^8.8.0", "proj4": "^2.20.2", @@ -40,6 +42,7 @@ "react-router-dom": "^7.7.1", "recharts": "^3.7.0", "remark-gfm": "^4.0.1", + "xlsx": "^0.18.5", "xstate": "^5.20.1" }, "devDependencies": { diff --git a/src/components/ContentPreview/ContentPreview.module.css b/src/components/ContentPreview/ContentPreview.module.css index fd8cf40..7ae9574 100644 --- a/src/components/ContentPreview/ContentPreview.module.css +++ b/src/components/ContentPreview/ContentPreview.module.css @@ -845,3 +845,121 @@ /* Popup-specific styles if needed */ } +/* ── Word (docx-preview) ────────────────────────────────────────────── */ +.docxContainer { + width: 100%; + height: 100%; + overflow: auto; + background: #e5e5e5; + padding: 1.5rem 0; +} + +.docxContainer * { + background-color: initial !important; +} + +.docxLoading { + text-align: center; + padding: 1rem; + color: var(--color-text); +} + +/* The docx-preview library creates a wrapper with .docx-wrapper containing + section elements that are sized and styled like real pages. */ +.docxContainer :global(.docx-wrapper) { + background: transparent !important; + padding: 0; +} + +.docxContainer :global(.docx-wrapper > section.docx) { + background: #fff !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + margin: 0 auto 1.5rem auto; +} + +.docxContainer :global(section.docx) * { + background-color: transparent !important; +} + +/* ── Excel (manual table) ───────────────────────────────────────────── */ +.excelTabs { + display: flex; + gap: 0.25rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.excelTab { + padding: 0.35rem 0.75rem; + border: 1px solid var(--color-border); + border-bottom: none; + border-radius: 4px 4px 0 0; + background: var(--color-background); + color: var(--color-text); + cursor: pointer; + font-size: 0.85rem; +} + +.excelTabActive { + background: var(--color-primary); + color: var(--color-on-primary, #fff); + border-color: var(--color-primary); +} + +.excelSheet { + flex: 1; + overflow: auto; + padding: 0; + background: var(--color-background); +} + +.excelTable { + border-collapse: collapse; + font-size: 0.85rem; + font-family: Calibri, "Segoe UI", Arial, sans-serif; + table-layout: fixed; + width: max-content; +} + +.excelTable td, +.excelTable th { + border: 1px solid #d0d7de; + padding: 2px 6px; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text); + background: var(--color-background) !important; +} + +.excelCorner, +.excelColHeader, +.excelRowHeader { + background: var(--color-surface, #f3f4f6) !important; + font-weight: 500; + color: var(--color-text-secondary, #4b5563); + text-align: center !important; + position: sticky; + z-index: 1; +} + +.excelColHeader { + top: 0; +} + +.excelRowHeader { + left: 0; + min-width: 40px; +} + +.excelCorner { + top: 0; + left: 0; + z-index: 2; +} + +.excelCell { + font-variant-numeric: tabular-nums; +} + diff --git a/src/components/ContentPreview/ContentPreview.tsx b/src/components/ContentPreview/ContentPreview.tsx index 2ee4d82..6f753a6 100644 --- a/src/components/ContentPreview/ContentPreview.tsx +++ b/src/components/ContentPreview/ContentPreview.tsx @@ -1,20 +1,24 @@ -import { useState, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { IoIosDownload, IoIosCopy } from 'react-icons/io'; import { Popup, PopupAction } from '../UiComponents/Popup/Popup'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useFileOperations } from '../../hooks/useFiles'; -import { - JsonRenderer, - ImageRenderer, - TextRenderer, - PdfRenderer, +import { + JsonRenderer, + ImageRenderer, + TextRenderer, + PdfRenderer, HtmlRenderer, - ApplicationRenderer, - UnsupportedRenderer, - LoadingRenderer, - ErrorRenderer + ApplicationRenderer, + UnsupportedRenderer, + LoadingRenderer, + ErrorRenderer, + WordRenderer, + ExcelRenderer, + isWordMimeType, + isExcelMimeType, } from './renderers'; import styles from './ContentPreview.module.css'; @@ -26,98 +30,91 @@ export interface ContentPreviewProps { mimeType?: string; } -export function ContentPreview({ - isOpen, - onClose, - fileId, - fileName, - mimeType +export function ContentPreview({ + isOpen, + onClose, + fileId, + fileName, + mimeType, }: ContentPreviewProps) { const { t } = useLanguage(); - const { handleFilePreview, handleFileDownload, previewingFiles, previewError, downloadingFiles } = useFileOperations(); - - // Debug logging to see what data we're receiving - useEffect(() => { - if (isOpen && import.meta.env.DEV) { - console.log('ContentPreview received:', { fileId, fileName, mimeType }); - } - }, [isOpen, fileId, fileName, mimeType]); - const [previewUrl, setPreviewUrl] = useState(null); - const [previewContent, setPreviewContent] = useState(null); - const [error, setError] = useState(null); - const [copySuccess, setCopySuccess] = useState(false); + const { + handleFilePreview, + handleFileDownload, + previewingFiles, + previewError, + downloadingFiles, + } = useFileOperations(); + + const [previewUrl, setPreviewUrl] = useState(null); + const [blob, setBlob] = useState(null); + const [textContent, setTextContent] = useState(null); + const [resolvedMime, setResolvedMime] = useState(null); + const [error, setError] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + + const cleanup = useCallback(() => { + setPreviewUrl(prev => { + if (prev) window.URL.revokeObjectURL(prev); + return null; + }); + setBlob(null); + setTextContent(null); + setResolvedMime(null); + }, []); + + const loadPreview = useCallback(async () => { + setError(null); + cleanup(); + + const result = await handleFilePreview(fileId, fileName, mimeType); + if (!result.success) { + setError(result.error || t('Vorschau konnte nicht geladen werden.')); + return; + } + + setPreviewUrl(result.previewUrl ?? null); + setBlob(result.blob ?? null); + setTextContent(result.textContent ?? null); + setResolvedMime(result.mimeType ?? mimeType ?? null); + }, [cleanup, fileId, fileName, handleFilePreview, mimeType, t]); + + useEffect(() => { + if (!isOpen || !fileId) { + cleanup(); + setError(null); + return; + } + + if (fileId === 'undefined' || fileId === 'null') { + setError(t('Ungültige Datei-ID')); + return; + } + if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') { + setError(t('Dateiname nicht verfügbar')); + return; + } + + loadPreview(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, fileId, fileName]); - // Clean up blob URL when component unmounts or preview changes useEffect(() => { return () => { - if (previewUrl) { - window.URL.revokeObjectURL(previewUrl); - } + if (previewUrl) window.URL.revokeObjectURL(previewUrl); }; }, [previewUrl]); - // Load preview when modal opens - useEffect(() => { - if (isOpen && fileId) { - // Check if we have valid data - if (!fileId || fileId === 'undefined' || fileId === 'null') { - setError(t('Ungültige Datei-ID')); - return; - } - if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') { - setError(t('Dateiname nicht verfügbar')); - return; - } - loadPreview(); - } else { - // Clean up when modal closes - if (previewUrl) { - window.URL.revokeObjectURL(previewUrl); - setPreviewUrl(null); - } - setError(null); - } - }, [isOpen, fileId, fileName, t]); - - - const loadPreview = async () => { - try { - setError(null); - setPreviewContent(null); - const result = await handleFilePreview(fileId, fileName, mimeType); - - if (result.success) { - if (result.previewUrl) { - setPreviewUrl(result.previewUrl); - } - if (result.decodedContent) { - setPreviewContent(result.decodedContent); - } - // If it's text content but MIME type says PDF, we'll handle it in renderPreview - } else { - setError(result.error || t('Vorschau konnte nicht geladen werden.')); - } - } catch (err) { - setError(t('Ein unerwarteter Fehler ist aufgetreten, während')); - } - }; + const effectiveMime = resolvedMime ?? mimeType; + const isPreviewing = previewingFiles.has(fileId); + const hasError = error || previewError; const handleCopyContent = async () => { + if (!textContent) return; try { - if (previewContent) { - await navigator.clipboard.writeText(previewContent); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 2000); - } else { - // Fallback: try to copy from preview URL if it's a text file - if (previewUrl && mimeType?.startsWith('text/')) { - const response = await fetch(previewUrl); - const text = await response.text(); - await navigator.clipboard.writeText(text); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 2000); - } - } + await navigator.clipboard.writeText(textContent); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); } catch (err) { console.error('Failed to copy content:', err); } @@ -131,160 +128,123 @@ export function ContentPreview({ } }; - const isPreviewing = previewingFiles.has(fileId); - const hasError = error || previewError; - - // Check if this is a corrupted PDF (text content instead of PDF) - const isCorruptedPdf = mimeType === 'application/pdf' && previewContent && !previewUrl; - - // Create action buttons for the popup header const actions: PopupAction[] = [ - // Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs - ...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{ - label: copySuccess ? t('In die Zwischenablage kopiert') : t(''), - icon: copySuccess ? '✓' : , - onClick: handleCopyContent, - disabled: !previewContent && !previewUrl, - variant: 'primary' as const - }] : []), - - // Download Button - hide for corrupted PDFs - ...(isCorruptedPdf ? [] : [{ - label: String(''), + ...(textContent + ? [ + { + label: copySuccess ? t('In die Zwischenablage kopiert') : '', + icon: copySuccess ? '✓' : , + onClick: handleCopyContent, + disabled: !textContent, + variant: 'primary' as const, + }, + ] + : []), + { + label: '', icon: downloadingFiles.has(fileId) ? undefined : , onClick: handleDownloadFile, disabled: downloadingFiles.has(fileId), loading: downloadingFiles.has(fileId), - variant: 'success' as const - }]) + variant: 'success' as const, + }, ]; const renderPreview = () => { - // Handle text content in PDF files (corrupted files) - check this first - if (previewContent && !previewUrl && mimeType === 'application/pdf') { - console.log('🔍 ContentPreview: Rendering corrupted PDF with text content'); + if (isPreviewing) return ; + if (hasError) return ; + if (!blob || !effectiveMime) return null; + + if (effectiveMime === 'application/json' && textContent) { + return ; + } + + if (isWordMimeType(effectiveMime, fileName)) { return ( - setError(t('PDF-Vorschau konnte nicht geladen werden'))} + setError(msg)} /> ); } - if (!previewUrl) { - if (isPreviewing) { - return ; - } - - if (hasError) { - return ; - } - - return null; - } - - // For JSON files with decoded content, use JsonRenderer - if (previewContent && mimeType === 'application/json') { - return ; - } - - if (mimeType === 'application/json') { + if (isExcelMimeType(effectiveMime, fileName)) { return ( -
-
- {t('JSON-Vorschau als Fallback')} -
- {t('Rohinhalt')} -
-
-
-            
-              {previewContent || t('Kein Inhalt verfügbar')}
-            
-          
-
+ setError(msg)} + /> ); } - // Determine preview type based on MIME type - const mimePrefix = mimeType?.split('/')[0]; + const mimePrefix = effectiveMime.split('/')[0]; - - switch (mimePrefix) { case 'image': + if (!previewUrl) return null; return ( - setError(t('Bildvorschau konnte nicht geladen werden'))} /> ); - + case 'text': - // Special handling for HTML files - if (mimeType === 'text/html') { + if (effectiveMime === 'text/html' && previewUrl) { return ( - setError(t('HTML-Vorschau konnte nicht geladen werden'))} /> ); } - return ( - setError(t('Textvorschau konnte nicht geladen werden'))} /> ); - + case 'application': - if (mimeType === 'application/pdf') { - console.log('🔍 ContentPreview passing normal PDF to PdfRenderer:', { - previewUrl, - previewContent: previewContent ? `${previewContent.substring(0, 50)}...` : null, - fileName, - mimeType - }); + if (effectiveMime === 'application/pdf' && previewUrl) { return ( - setError(t('PDF-Vorschau konnte nicht geladen werden'))} /> ); } - - if (mimeType === 'application/html') { + if (effectiveMime === 'application/html' && previewUrl) { return ( - setError(t('HTML-Vorschau konnte nicht geladen werden'))} /> ); } - - - return ( - setError(t('Vorschau wird für dieses Format nicht unterstützt'))} + + setError(t('Vorschau wird für dieses Format nicht unterstützt')) + } /> ); - + default: - return ; + return ; } }; @@ -297,12 +257,9 @@ export function ContentPreview({ className={styles.contentPreviewPopup} actions={actions} > -
- {renderPreview()} -
+
{renderPreview()}
); } export default ContentPreview; - diff --git a/src/components/ContentPreview/renderers/ExcelRenderer.tsx b/src/components/ContentPreview/renderers/ExcelRenderer.tsx new file mode 100644 index 0000000..b4133f9 --- /dev/null +++ b/src/components/ContentPreview/renderers/ExcelRenderer.tsx @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useState } from 'react'; +import * as XLSX from 'xlsx'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import styles from '../ContentPreview.module.css'; + +interface ExcelRendererProps { + blob: Blob; + fileName: string; + onError: (message: string) => void; +} + +const EXCEL_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/csv', + 'application/csv', +]); + +export function isExcelMimeType(mimeType?: string, fileName?: string): boolean { + if (mimeType && EXCEL_MIME_TYPES.has(mimeType)) return true; + if (fileName && /\.(xlsx|xls|xlsm|xlsb|ods|csv)$/i.test(fileName)) return true; + return false; +} + +interface RenderedCell { + display: string; + rawType: 'n' | 's' | 'b' | 'd' | 'e' | 'z' | string; + rowspan: number; + colspan: number; + skip: boolean; +} + +interface RenderedSheet { + name: string; + cols: number; + rows: number; + colWidthsPx: number[]; + rowHeightsPx: (number | null)[]; + cells: RenderedCell[][]; +} + +function renderSheet(ws: XLSX.WorkSheet, name: string): RenderedSheet { + const ref = ws['!ref']; + if (!ref) { + return { name, cols: 0, rows: 0, colWidthsPx: [], rowHeightsPx: [], cells: [] }; + } + + const range = XLSX.utils.decode_range(ref); + const rows = range.e.r - range.s.r + 1; + const cols = range.e.c - range.s.c + 1; + + const cells: RenderedCell[][] = Array.from({ length: rows }, () => + Array.from({ length: cols }, () => ({ + display: '', + rawType: 'z', + rowspan: 1, + colspan: 1, + skip: false, + })), + ); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const address = XLSX.utils.encode_cell({ r: r + range.s.r, c: c + range.s.c }); + const cell = ws[address] as XLSX.CellObject | undefined; + if (!cell) continue; + const display = cell.w ?? (cell.v !== undefined && cell.v !== null ? String(cell.v) : ''); + cells[r][c].display = display; + cells[r][c].rawType = cell.t ?? 'z'; + } + } + + const merges = ws['!merges'] ?? []; + for (const merge of merges) { + const rs = merge.s.r - range.s.r; + const cs = merge.s.c - range.s.c; + const re = merge.e.r - range.s.r; + const ce = merge.e.c - range.s.c; + if (rs < 0 || cs < 0 || re >= rows || ce >= cols) continue; + + cells[rs][cs].rowspan = re - rs + 1; + cells[rs][cs].colspan = ce - cs + 1; + + for (let r = rs; r <= re; r++) { + for (let c = cs; c <= ce; c++) { + if (r === rs && c === cs) continue; + cells[r][c].skip = true; + } + } + } + + const colWidthsPx: number[] = []; + const colsMeta = ws['!cols'] ?? []; + for (let c = 0; c < cols; c++) { + const meta = colsMeta[c + range.s.c]; + if (meta?.wpx) { + colWidthsPx.push(meta.wpx); + } else if (meta?.wch) { + colWidthsPx.push(Math.round(meta.wch * 7 + 8)); + } else if (meta?.width) { + colWidthsPx.push(Math.round(meta.width * 7 + 8)); + } else { + colWidthsPx.push(80); + } + } + + const rowHeightsPx: (number | null)[] = []; + const rowsMeta = ws['!rows'] ?? []; + for (let r = 0; r < rows; r++) { + const meta = rowsMeta[r + range.s.r]; + if (meta?.hpx) { + rowHeightsPx.push(meta.hpx); + } else if (meta?.hpt) { + rowHeightsPx.push(Math.round((meta.hpt * 4) / 3)); + } else { + rowHeightsPx.push(null); + } + } + + return { name, cols, rows, colWidthsPx, rowHeightsPx, cells }; +} + +function alignmentForCell(cell: RenderedCell): 'left' | 'right' | 'center' { + if (cell.rawType === 'n' || cell.rawType === 'd') return 'right'; + if (cell.rawType === 'b') return 'center'; + return 'left'; +} + +function colLabel(index: number): string { + let result = ''; + let n = index; + do { + result = String.fromCharCode(65 + (n % 26)) + result; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return result; +} + +export function ExcelRenderer({ blob, fileName, onError }: ExcelRendererProps) { + const { t } = useLanguage(); + const [sheets, setSheets] = useState([]); + const [activeSheet, setActiveSheet] = useState(null); + const [loading, setLoading] = useState(true); + const [localError, setLocalError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setLocalError(null); + + blob + .arrayBuffer() + .then(buffer => { + const workbook = XLSX.read(buffer, { + type: 'array', + cellDates: true, + cellNF: true, + cellStyles: true, + }); + const parsed = workbook.SheetNames.map(name => + renderSheet(workbook.Sheets[name], name), + ); + if (cancelled) return; + setSheets(parsed); + setActiveSheet(parsed[0]?.name ?? null); + setLoading(false); + }) + .catch(err => { + if (cancelled) return; + const msg = err?.message ?? t('Tabelle konnte nicht gerendert werden.'); + setLocalError(msg); + setLoading(false); + onError(msg); + }); + + return () => { + cancelled = true; + }; + }, [blob, onError, t]); + + const current = useMemo( + () => sheets.find(s => s.name === activeSheet) ?? null, + [sheets, activeSheet], + ); + + if (loading) { + return ( +
+
+ {t('Tabelle wird geladen...')} +
+
+ ); + } + + if (localError) { + return ( +
+
+ {fileName} +
+
{localError}
+
+ ); + } + + return ( +
+
+ {fileName} + {sheets.length > 1 && ( +
+ {sheets.map(sheet => ( + + ))} +
+ )} +
+
+ {current && current.rows > 0 ? ( + + + + {current.colWidthsPx.map((w, i) => ( + + ))} + + + + + ))} + + + + {current.cells.map((row, rIdx) => ( + + + {row.map((cell, cIdx) => { + if (cell.skip) return null; + return ( + + ); + })} + + ))} + +
+ {current.colWidthsPx.map((_, i) => ( + + {colLabel(i)} +
{rIdx + 1} + {cell.display} +
+ ) : ( +
+ {t('Dieses Arbeitsblatt ist leer.')} +
+ )} +
+
+ ); +} diff --git a/src/components/ContentPreview/renderers/WordRenderer.tsx b/src/components/ContentPreview/renderers/WordRenderer.tsx new file mode 100644 index 0000000..6623f3e --- /dev/null +++ b/src/components/ContentPreview/renderers/WordRenderer.tsx @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { renderAsync } from 'docx-preview'; +import { useLanguage } from '../../../providers/language/LanguageContext'; +import styles from '../ContentPreview.module.css'; + +interface WordRendererProps { + blob: Blob; + fileName: string; + mimeType?: string; + onError: (message: string) => void; +} + +const SUPPORTED_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]); + +export function isWordMimeType(mimeType?: string, fileName?: string): boolean { + if (mimeType && SUPPORTED_MIME_TYPES.has(mimeType)) return true; + if (fileName && /\.docx$/i.test(fileName)) return true; + return false; +} + +export function WordRenderer({ blob, fileName, mimeType, onError }: WordRendererProps) { + const { t } = useLanguage(); + const bodyRef = useRef(null); + const styleRef = useRef(null); + const [loading, setLoading] = useState(true); + const [localError, setLocalError] = useState(null); + + const isLegacyDoc = useMemo( + () => mimeType === 'application/msword' || /\.doc$/i.test(fileName), + [mimeType, fileName], + ); + + useEffect(() => { + let cancelled = false; + + if (isLegacyDoc) { + const msg = t( + 'Das alte Word-Format (.doc) wird nicht unterstützt. Bitte konvertiere die Datei in .docx.', + ); + setLocalError(msg); + setLoading(false); + onError(msg); + return; + } + + const body = bodyRef.current; + const styleContainer = styleRef.current; + if (!body || !styleContainer) return; + + body.innerHTML = ''; + styleContainer.innerHTML = ''; + setLoading(true); + setLocalError(null); + + renderAsync(blob, body, styleContainer, { + className: 'docx-preview', + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + ignoreFonts: false, + breakPages: true, + experimental: true, + trimXmlDeclaration: true, + useBase64URL: true, + renderHeaders: true, + renderFooters: true, + renderFootnotes: true, + renderEndnotes: true, + }) + .then(() => { + if (cancelled) return; + setLoading(false); + }) + .catch(err => { + if (cancelled) return; + const msg = + err?.message ?? t('Word-Dokument konnte nicht gerendert werden.'); + setLocalError(msg); + setLoading(false); + onError(msg); + }); + + return () => { + cancelled = true; + }; + }, [blob, isLegacyDoc, onError, t]); + + if (localError) { + return ( +
+
+ {fileName} +
+
{localError}
+
+ ); + } + + return ( +
+ {loading && ( +
{t('Word-Dokument wird geladen...')}
+ )} +
+
+
+ ); +} diff --git a/src/components/ContentPreview/renderers/index.ts b/src/components/ContentPreview/renderers/index.ts index 0aa8e32..223f4e4 100644 --- a/src/components/ContentPreview/renderers/index.ts +++ b/src/components/ContentPreview/renderers/index.ts @@ -7,4 +7,6 @@ export { ApplicationRenderer } from './ApplicationRenderer'; export { UnsupportedRenderer } from './UnsupportedRenderer'; export { LoadingRenderer } from './LoadingRenderer'; export { ErrorRenderer } from './ErrorRenderer'; +export { WordRenderer, isWordMimeType } from './WordRenderer'; +export { ExcelRenderer, isExcelMimeType } from './ExcelRenderer'; diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index e5809bd..fea4872 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import api from '../api'; -import { useFileOperations } from '../hooks/useFiles'; +import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles'; import type { FolderInfo } from '../api/fileApi'; import type { FileNode } from '../components/FolderTree/FolderTree'; @@ -31,7 +31,7 @@ interface FileContextType { handleDownloadFolder: (folderId: string, folderName: string) => Promise; handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise; handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>; - handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<{ success: boolean; previewUrl?: string | null; blob?: Blob | null; isJsonContent?: boolean; decodedContent?: any; isTextContent?: boolean; error?: string }>; + handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise; handleFileDownload: (fileId: string, fileName: string) => Promise; uploadingFile: boolean; deletingFiles: Set; @@ -316,7 +316,7 @@ export function FileProvider({ children }: { children: React.ReactNode }) { handleDownloadFolder, handleFileDelete, handleFileUpload, - handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'], + handleFilePreview, handleFileDownload: async (fileId: string, fileName: string) => { await handleFileDownload(fileId, fileName); }, diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts index 04306e8..b2b59d4 100644 --- a/src/hooks/useFiles.ts +++ b/src/hooks/useFiles.ts @@ -15,6 +15,15 @@ import { type FolderInfo, } from '../api/fileApi'; +export interface FilePreviewResult { + success: boolean; + previewUrl?: string; + blob?: Blob; + mimeType?: string; + textContent?: string | null; + error?: string; +} + // File interfaces - exactly matching backend FileItem model export interface FileInfo { id: string; @@ -588,334 +597,67 @@ export function useFileOperations() { } }; - const handleFilePreview = async (fileId: string, fileName: string, mimeType?: string) => { + const handleFilePreview = async ( + fileId: string, + fileName: string, + _mimeType?: string, + ): Promise => { setPreviewError(null); setPreviewingFiles(prev => new Set(prev).add(fileId)); - + try { - - // For PDF files, try JSON response first (API returns base64-encoded PDF) - if (mimeType === 'application/pdf') { - - try { - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'json', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const jsonResponse = response.data; - + const response = await api.get(`/api/files/${fileId}/preview`, { + responseType: 'json', + }); + const data = response.data; - - // Check if response has base64-encoded PDF content - if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) { - let content = jsonResponse.content; - - // The content field contains base64-encoded JSON, so decode it first - if (typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) { - try { - const decodedJsonString = atob(content); - - // Parse the decoded JSON string - const nestedJson = JSON.parse(decodedJsonString); - - if (nestedJson && typeof nestedJson === 'object' && 'content' in nestedJson) { - const innerContent = nestedJson.content; - const isBase64 = /^[A-Za-z0-9+/=]+$/.test(innerContent); - - - - if (isBase64) { - // It's base64-encoded PDF content - content = innerContent; - } else { - // It's plain text content, not a PDF - // Return the text content for the FilePreview to handle as text - return { - success: true, - previewUrl: null, - blob: null, - isJsonContent: true, - decodedContent: innerContent, - isTextContent: true - }; - } - } - } catch (decodeError) { - console.warn('⚠️ Failed to decode base64 content or parse JSON:', decodeError); - } - } - - - - // Decode base64 content - let decodedContent; - try { - decodedContent = atob(content); - - - // Verify it's actually a PDF - const isPDF = decodedContent.startsWith('%PDF'); - - - if (!isPDF) { - console.warn('⚠️ Decoded content does not appear to be a valid PDF'); - } - - } catch (decodeError) { - console.error('❌ Failed to decode base64 PDF content:', decodeError); - throw new Error('Failed to decode PDF content'); - } - - // Create a blob from the decoded PDF content - // Convert string to Uint8Array for proper binary handling - const uint8Array = new Uint8Array(decodedContent.length); - for (let i = 0; i < decodedContent.length; i++) { - uint8Array[i] = decodedContent.charCodeAt(i); - } - - const blob = new Blob([uint8Array], { type: 'application/pdf' }); - const url = window.URL.createObjectURL(blob); - - - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent }; - } else { - throw new Error('No content field in PDF response'); - } - } catch (jsonError) { - - - // Fallback to blob response - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'blob', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const previewData = response.data; - - - - const url = window.URL.createObjectURL(previewData); - - return { success: true, previewUrl: url, blob: previewData, isJsonContent: false }; - } + if (!data || typeof data !== 'object' || typeof data.content !== 'string' || !data.mimeType) { + throw new Error('Invalid preview response from server'); } - - // For image files, try JSON response first (API returns base64-encoded images) - if (mimeType?.startsWith('image/')) { - - try { - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'json', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const jsonResponse = response.data; - + const { content, mimeType: responseMime, isText } = data as { + content: string; + mimeType: string; + isText?: boolean; + encoding?: string | null; + }; - - // Check if response has base64-encoded image content - if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) { - let content = jsonResponse.content; - const responseMimeType = jsonResponse.mimeType || mimeType; - - // The content field contains base64-encoded data, decode it first - if (typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) { + let blob: Blob; + let textContent: string | null = null; - try { - const decodedString = atob(content); - - - // Check if it's JSON (nested structure) or direct image data - if (decodedString.startsWith('{')) { - // It's JSON, parse it - const nestedJson = JSON.parse(decodedString); - - - if (nestedJson && typeof nestedJson === 'object' && 'content' in nestedJson) { - const innerContent = nestedJson.content; - const isBase64 = /^[A-Za-z0-9+/=]+$/.test(innerContent); - - - - if (isBase64) { - // It's base64-encoded image content - content = innerContent; - } else { - throw new Error('Inner content is not base64-encoded'); - } - } - } else if (decodedString.startsWith('\x89PNG') || decodedString.startsWith('\xFF\xD8\xFF') || decodedString.startsWith('GIF8') || decodedString.startsWith('RIFF')) { - // It's direct image data, use it as is - content = btoa(decodedString); // Re-encode as base64 for processing - } else { - throw new Error('Decoded content is neither JSON nor image data'); - } - } catch (decodeError) { - console.warn('⚠️ Failed to decode base64 content:', decodeError); - throw decodeError; - } - } - - - - // Decode base64 content - let decodedContent; - try { - decodedContent = atob(content); - - - // Verify it's actually an image by checking for common image headers - const isJPEG = decodedContent.startsWith('\xFF\xD8\xFF'); - const isPNG = decodedContent.startsWith('\x89PNG\r\n\x1a\n'); - const isGIF = decodedContent.startsWith('GIF8'); - const isWebP = decodedContent.startsWith('RIFF') && decodedContent.includes('WEBP'); - - - - if (!isJPEG && !isPNG && !isGIF && !isWebP) { - console.warn('⚠️ Decoded content does not appear to be a valid image'); - } - - } catch (decodeError) { - console.error('❌ Failed to decode base64 image content:', decodeError); - throw new Error('Failed to decode image content'); - } - - // Create a blob from the decoded image content - // Convert string to Uint8Array for proper binary handling - const uint8Array = new Uint8Array(decodedContent.length); - for (let i = 0; i < decodedContent.length; i++) { - uint8Array[i] = decodedContent.charCodeAt(i); - } - - const blob = new Blob([uint8Array], { type: responseMimeType }); - const url = window.URL.createObjectURL(blob); - - - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent }; - } else { - throw new Error('No content field in image response'); - } - } catch (jsonError) { - - // Fallback to blob response - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'blob', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const previewData = response.data; - - - const url = window.URL.createObjectURL(previewData); - - return { success: true, previewUrl: url, blob: previewData, isJsonContent: false }; + if (isText) { + textContent = content; + blob = new Blob([content], { type: responseMime }); + } else { + const binaryString = atob(content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); } + blob = new Blob([bytes], { type: responseMime }); } - - // For other files, first try to get JSON response (for text-based files) - try { - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'json', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const jsonResponse = response.data; - - // Check if response has content field (structured response) - if (jsonResponse && typeof jsonResponse === 'object' && 'content' in jsonResponse) { - const content = jsonResponse.content; - const mimeType = jsonResponse.mimeType || 'text/plain'; - - - // Check if content is base64 encoded (common pattern) - let decodedContent = content; - try { - // Try to decode as base64 if it looks like base64 - if (content && typeof content === 'string' && /^[A-Za-z0-9+/=]+$/.test(content)) { - decodedContent = atob(content); + const previewUrl = window.URL.createObjectURL(blob); - } - } catch (decodeError) { - decodedContent = content; - } - - // Create a blob from the (possibly decoded) content - const blob = new Blob([decodedContent], { type: mimeType }); - const url = window.URL.createObjectURL(blob); - - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent }; - } else if (jsonResponse && typeof jsonResponse === 'object' && 'result' in jsonResponse) { - // Handle base64 encoded content in 'result' field - - try { - // Decode base64 content - const decodedContent = atob(jsonResponse.result); - const mimeType = jsonResponse.mimeType || 'application/json'; - - - // Create a blob from the decoded content - const blob = new Blob([decodedContent], { type: mimeType }); - const url = window.URL.createObjectURL(blob); - - - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true, decodedContent: decodedContent }; - } catch (decodeError) { - console.error('❌ Failed to decode base64 content:', decodeError); - // Fallback to treating as raw JSON - const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true }; - } - } else { - // If it's not structured JSON, treat as raw content - const blob = new Blob([JSON.stringify(jsonResponse, null, 2)], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - - return { success: true, previewUrl: url, blob: blob, isJsonContent: true }; - } - } catch (jsonError) { - - // Fallback to blob response for binary files - const response = await api.get(`/api/files/${fileId}/preview`, { - responseType: 'blob', - validateStatus: function (status: number) { - return status >= 200 && status < 300; - } - }); - const previewData = response.data; - - - // Create a blob URL for preview - const url = window.URL.createObjectURL(previewData); - - return { success: true, previewUrl: url, blob: previewData, isJsonContent: false }; - } + return { + success: true, + previewUrl, + blob, + mimeType: responseMime, + textContent, + }; } catch (error: any) { - console.error(`❌ Preview failed for ${fileName}:`, error); - let errorMessage = error.message; - - if (error.response?.status === 404) { + console.error(`Preview failed for ${fileName}:`, error); + let errorMessage = error?.message ?? 'Unknown error'; + + if (error?.response?.status === 404) { errorMessage = `File "${fileName}" not found or has been deleted.`; - } else if (error.response?.status === 403) { + } else if (error?.response?.status === 403) { errorMessage = `No permission to preview "${fileName}".`; - } else if (error.response?.status === 415) { + } else if (error?.response?.status === 415) { errorMessage = `File type "${fileName}" is not supported for preview.`; } - + setPreviewError(errorMessage); return { success: false, error: errorMessage }; } finally { diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 60a4295..bf63bf1 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -14,7 +14,7 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import FolderTree from '../../components/FolderTree/FolderTree'; import { useResizablePanels } from '../../hooks/useResizablePanels'; -import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; +import { FaSync, FaUpload, FaDownload, FaFolderPlus } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; @@ -66,7 +66,6 @@ export const FilesPage: React.FC = () => { handleFileDeleteMultiple, handleFileUpload, handleFileUpdate, - handleFilePreview, handleInlineUpdate, deletingFiles, downloadingFiles, @@ -278,13 +277,6 @@ export const FilesPage: React.FC = () => { await handleFileDownload(file.id, file.fileName); }; - const handlePreview = async (file: UserFile) => { - const result = await handleFilePreview(file.id, file.fileName, file.mimeType); - if (result.success && result.previewUrl) { - window.open(result.previewUrl, '_blank'); - } - }; - const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleFileSelect = async (e: React.ChangeEvent) => { @@ -465,6 +457,15 @@ export const FilesPage: React.FC = () => { ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) } actionButtons={[ + { + type: 'view' as const, + onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ }, + title: t('Vorschau'), + idField: 'id', + nameField: 'fileName', + typeField: 'mimeType', + loadingStateName: 'previewingFiles', + }, ...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, @@ -486,13 +487,6 @@ export const FilesPage: React.FC = () => { title: t('Herunterladen'), loading: (row: UserFile) => downloadingFiles.has(row.id), }, - { - id: 'preview', - icon: , - onClick: handlePreview, - title: t('Vorschau'), - loading: (row: UserFile) => previewingFiles.has(row.id), - }, ]} onDelete={handleDelete} onDeleteMultiple={handleDeleteMultiple} @@ -503,6 +497,7 @@ export const FilesPage: React.FC = () => { handleDelete: handleFileDelete, handleInlineUpdate, updateOptimistically: updateFileOptimistically, + previewingFiles, }} emptyMessage={t('Keine Dateien gefunden')} /> From dc174f570cfad6e0f15b0a05815c5b89468cbb4e Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 14:41:44 +0200 Subject: [PATCH 12/15] bugfix(FIL-04) --- .../FormGeneratorTable/FormGeneratorTable.tsx | 4 +-- src/pages/basedata/FilesPage.tsx | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 807ad5e..6b71363 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -185,8 +185,8 @@ export interface FormGeneratorTableProps { getRowDataAttributes?: (row: T, index: number) => Record; // For passing hook data to action buttons hookData?: any; // Contains all hook data: refetch, operations, loading states, etc. - // Custom empty message when table is empty - emptyMessage?: string; + // Custom empty message when table is empty (string or custom ReactNode) + emptyMessage?: React.ReactNode; // API endpoint for CSV export (e.g. "/api/users/"). If provided, the CSV export button is shown. apiEndpoint?: string; // Grouping configuration diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index bf63bf1..81b62d8 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -152,6 +152,29 @@ export const FilesPage: React.FC = () => { })); }, [folders]); + const selectedFolderName = useMemo(() => { + if (!selectedFolderId) return null; + return folders.find(f => f.id === selectedFolderId)?.name ?? null; + }, [folders, selectedFolderId]); + + const emptyTableMessage = useMemo(() => { + if (!selectedFolderId) { + return t('Keine Dateien gefunden'); + } + return ( +
+
+ {selectedFolderName + ? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName }) + : t('Dieser Ordner ist leer.')} +
+
+ {t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')} +
+
+ ); + }, [selectedFolderId, selectedFolderName, t]); + // ── Columns ─────────────────────────────────────────────────────────── const columns = useMemo(() => { const hiddenColumns = ['id', 'fileHash', 'folderId']; @@ -499,7 +522,7 @@ export const FilesPage: React.FC = () => { updateOptimistically: updateFileOptimistically, previewingFiles, }} - emptyMessage={t('Keine Dateien gefunden')} + emptyMessage={emptyTableMessage} />
From 5dd4741a0fbebc20fe44ca0ccdd95b4ed2dc69c2 Mon Sep 17 00:00:00 2001 From: Ida Date: Fri, 17 Apr 2026 15:05:34 +0200 Subject: [PATCH 13/15] bugfix(PRM-01) --- .../DeleteActionButton/DeleteActionButton.tsx | 14 +- .../FormGeneratorTable/FormGeneratorTable.tsx | 126 +++++++++++++++++- src/pages/basedata/PromptsPage.tsx | 30 +++-- 3 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx index b040d45..f3e9ab4 100644 --- a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx @@ -118,15 +118,11 @@ export function DeleteActionButton({ const success = await handleDelete(itemId); if (success) { - // If we used optimistic removal, don't refetch immediately - // The item is already removed from UI, and refetch might bring it back - if (removeOptimistically) { - // Only refetch if there was an error or if we need to sync other changes - // For now, we trust the optimistic removal worked - } else { - // No optimistic removal, refetch immediately - refetch(); - } + // Always refetch after a successful delete. The server has actually + // removed the row, so fresh data won't bring it back — and this is + // what re-syncs pagination.totalItems (and clears any optimistic + // hidden-row state maintained by FormGeneratorTable). + refetch(); onSuccess?.(row); } else { // Refetch to restore the item in case of failure diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 6b71363..ef353ca 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -339,7 +339,7 @@ export function FormGeneratorTable>({ onRefresh, className = '', getRowDataAttributes, - hookData, + hookData: hookDataProp, emptyMessage, apiEndpoint, groupBy, @@ -356,6 +356,108 @@ export function FormGeneratorTable>({ // When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined); const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]); + + // ── Optimistic row hiding + adjusted header count ─────────────────────── + // We synthesize `removeOptimistically` at the FormGenerator layer so every + // page gets instant delete feedback (row hidden + "N Einträge" decremented) + // regardless of whether the underlying hook implements it. + // + // 1. `optimisticallyDeletedIds` tracks which rows are currently hidden. + // 2. `displayData` below is filtered to exclude those IDs. + // 3. `pagination.totalItems`/`totalPages` are reduced by the set size. + // 4. The set is cleared whenever a fresh `pagination` reference arrives + // from the hook (i.e. after a successful refetch establishes server + // truth). + const [optimisticallyDeletedIds, setOptimisticallyDeletedIds] = useState>(() => new Set()); + const previousPaginationRef = useRef(hookDataProp?.pagination); + useEffect(() => { + if (hookDataProp?.pagination !== previousPaginationRef.current) { + previousPaginationRef.current = hookDataProp?.pagination; + if (optimisticallyDeletedIds.size > 0) setOptimisticallyDeletedIds(new Set()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hookDataProp?.pagination]); + + // Snapshot of the current table state so a "naked" refetch() call (e.g. + // from DeleteActionButton) still includes page/pageSize/filters/sort/search. + // Without this, the hook issues a no-params GET which, for paginated + // endpoints, returns `pagination: null` and leaves `totalItems` stale. + const tableStateRef = useRef({ + page: 1, + pageSize: pageSize, + search: '', + filters: {} as Record, + sort: [] as Array<{ key: string; direction: 'asc' | 'desc' }>, + }); + + const hookData = useMemo(() => { + if (!hookDataProp) return hookDataProp; + + const origRemove = + hookDataProp.removeOptimistically || hookDataProp.removeFileOptimistically; + const wrappedRemove = (id: string) => { + if (origRemove) origRemove(id); + setOptimisticallyDeletedIds(prev => { + const next = new Set(prev); + next.add(String(id)); + return next; + }); + }; + + const wrappedRefetch = hookDataProp.refetch + ? async (params?: any) => { + const hasPaginationInfo = + params && (params.page !== undefined || params.pageSize !== undefined); + if (hasPaginationInfo) { + return await hookDataProp.refetch(params); + } + const s = tableStateRef.current; + const finalParams: any = { + page: s.page, + pageSize: s.pageSize, + ...(params || {}), + }; + if (s.search && s.search.trim()) finalParams.search = s.search.trim(); + const activeFilters: Record = {}; + Object.entries(s.filters).forEach(([k, v]) => { + if (v !== undefined && v !== '') activeFilters[k] = v; + }); + if (Object.keys(activeFilters).length) finalParams.filters = activeFilters; + if (s.sort.length) { + finalParams.sort = s.sort.map(sc => ({ field: sc.key, direction: sc.direction })); + } + return await hookDataProp.refetch(finalParams); + } + : hookDataProp.refetch; + + const origPagination = hookDataProp.pagination; + const hasNumericTotal = + origPagination && typeof origPagination.totalItems === 'number'; + const delta = -optimisticallyDeletedIds.size; + const adjustedTotalItems = hasNumericTotal + ? Math.max(0, origPagination.totalItems + delta) + : undefined; + const adjustedTotalPages = + hasNumericTotal && origPagination.pageSize + ? Math.max(1, Math.ceil((adjustedTotalItems ?? 0) / origPagination.pageSize)) + : origPagination?.totalPages; + + const adjustedPagination = hasNumericTotal + ? { + ...origPagination, + totalItems: adjustedTotalItems, + totalPages: adjustedTotalPages, + } + : origPagination; + + return { + ...hookDataProp, + removeOptimistically: wrappedRemove, + removeFileOptimistically: wrappedRemove, + refetch: wrappedRefetch, + pagination: adjustedPagination, + }; + }, [hookDataProp, optimisticallyDeletedIds]); // Use provided columns from Pydantic attribute definitions // NO AUTO-DETECTION - columns must come from backend attribute definitions // Use a ref to cache columns so they persist across data changes (e.g., when filtering) @@ -440,6 +542,18 @@ export function FormGeneratorTable>({ return () => clearTimeout(timer); }, [searchTerm]); + + // Keep tableStateRef in sync so a "naked" refetch() call (from e.g. + // DeleteActionButton) can inject the current page/pageSize/filters/sort. + useEffect(() => { + tableStateRef.current = { + page: currentPage, + pageSize: currentPageSize, + search: debouncedSearchTerm, + filters, + sort: sortConfigs, + }; + }, [currentPage, currentPageSize, debouncedSearchTerm, filters, sortConfigs]); // Call backend when filters/search/sort/pagination change useEffect(() => { @@ -740,9 +854,13 @@ export function FormGeneratorTable>({ return value.length > 8 ? `${value.substring(0, 8)}...` : value; }, [fkCache]); - // Data is already filtered, sorted, and paginated by the backend - // No client-side processing needed - const displayData = data; + // Data is already filtered, sorted, and paginated by the backend. + // Client-side only filters out rows that were just optimistically deleted + // so the UI updates instantly before the server's next refetch response. + const displayData = useMemo(() => { + if (optimisticallyDeletedIds.size === 0) return data; + return data.filter(row => !optimisticallyDeletedIds.has(String(row?.[_idField]))); + }, [data, optimisticallyDeletedIds, _idField]); // Grouping: Group data by groupBy field if specified const groupedData = useMemo(() => { diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 2fa7740..8e56068 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -5,7 +5,7 @@ * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { usePrompts, usePromptOperations } from '../../hooks/usePrompts'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; @@ -49,10 +49,22 @@ export const PromptsPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingPrompt, setEditingPrompt] = useState(null); + // ── Table refetch wrapper (stable signature used by FormGeneratorTable) ── + const _tableRefetch = useCallback(async (params?: any) => { + await refetch(params); + }, [refetch]); + + // ── Refresh-All for the header "Aktualisieren" button ──────────────────── + // Forces a paginated request so the cache key matches what the table uses + // internally. This guarantees fresh (non-cached) data is pulled in. + const _refreshAll = useCallback(async () => { + await _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); + // Initial fetch useEffect(() => { - refetch(); - }, []); + _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { @@ -114,7 +126,7 @@ export const PromptsPage: React.FC = () => { }); if (result?.success) { setShowCreateModal(false); - refetch(); + _refreshAll(); } }; @@ -127,7 +139,7 @@ export const PromptsPage: React.FC = () => { }); if (result.success) { setEditingPrompt(null); - refetch(); + _refreshAll(); } }; @@ -135,7 +147,7 @@ export const PromptsPage: React.FC = () => { const handleDelete = async (prompt: Prompt) => { const success = await handlePromptDelete(prompt.id); if (success) { - refetch(); + _refreshAll(); } }; @@ -152,7 +164,7 @@ export const PromptsPage: React.FC = () => {
⚠️

{t('Fehler beim Laden der Prompts: {detail}', { detail: String(error) })}

-
@@ -170,7 +182,7 @@ export const PromptsPage: React.FC = () => {