From 1c2a196192183abdf9055f50528cddfb76c519c6 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 21 Apr 2026 23:49:50 +0200 Subject: [PATCH] fixes udb, outlook, workflow --- .../editor/Automation2FlowEditor.tsx | 21 ++ .../FlowEditor/editor/FlowCanvas.tsx | 33 ++- .../FlowEditor/editor/NodeConfigPanel.tsx | 101 ++++++- .../FolderTree/FolderTree.module.css | 10 + src/components/FolderTree/FolderTree.tsx | 261 +++++++++++++++++- .../actions/FileActionBottomSheet.module.css | 103 +++++++ .../actions/FileActionBottomSheet.tsx | 83 ++++++ .../actions/FileActionContextMenu.module.css | 103 +++++++ .../actions/FileActionContextMenu.tsx | 146 ++++++++++ src/components/FolderTree/actions/registry.ts | 218 +++++++++++++++ src/components/FolderTree/actions/types.ts | 87 ++++++ .../FolderTree/actions/usePointerLongPress.ts | 75 +++++ .../FolderTree/actions/useViewMode.ts | 25 ++ .../FormGeneratorTable/FormGeneratorTable.tsx | 50 +++- src/components/UnifiedDataBar/FilesTab.tsx | 60 +++- src/components/UnifiedDataBar/SourcesTab.tsx | 168 ++++++++++- .../UnifiedDataBar/UnifiedDataBar.tsx | 17 ++ src/hooks/useConfirm.tsx | 9 +- src/hooks/usePrompt.tsx | 9 +- src/pages/basedata/ConnectionsPage.tsx | 2 + src/pages/basedata/FilesPage.tsx | 2 + src/pages/basedata/PromptsPage.tsx | 2 + .../views/trustee/TrusteeDataTablesView.tsx | 180 ++++++++---- .../trustee/dataTables/TrusteeDataTab.tsx | 2 + src/utils/applyFrontendFormat.ts | 181 ++++++++++++ 25 files changed, 1879 insertions(+), 69 deletions(-) create mode 100644 src/components/FolderTree/actions/FileActionBottomSheet.module.css create mode 100644 src/components/FolderTree/actions/FileActionBottomSheet.tsx create mode 100644 src/components/FolderTree/actions/FileActionContextMenu.module.css create mode 100644 src/components/FolderTree/actions/FileActionContextMenu.tsx create mode 100644 src/components/FolderTree/actions/registry.ts create mode 100644 src/components/FolderTree/actions/types.ts create mode 100644 src/components/FolderTree/actions/usePointerLongPress.ts create mode 100644 src/components/FolderTree/actions/useViewMode.ts create mode 100644 src/utils/applyFrontendFormat.ts diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 5a0d0d1..35e42b9 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -22,6 +22,7 @@ import { archiveVersion, createTemplateFromWorkflow, copyTemplate, + importWorkflowFromFile, type NodeType, type NodeTypeCategory, type Automation2Graph, @@ -122,6 +123,7 @@ export const Automation2FlowEditor: React.FC = ({ in instanceId, mandateId: mandateId || '', featureInstanceId: instanceId, + surface: 'graphEditor', }), [instanceId, mandateId]); const [versions, setVersions] = useState([]); const [currentVersionId, setCurrentVersionId] = useState(null); @@ -722,6 +724,10 @@ export const Automation2FlowEditor: React.FC = ({ in hideTabs={['chats']} onFileSelect={onFileSelect} onSourcesChanged={onSourcesChanged} + onWorkflowImportedFromFile={async (workflowId) => { + await loadWorkflows(); + handleWorkflowSelect(workflowId); + }} /> )} @@ -771,6 +777,21 @@ export const Automation2FlowEditor: React.FC = ({ in getCategoryIcon={getCategoryIcon} onSelectionChange={setSelectedNode} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} + onExternalDrop={async (mime, payload) => { + if (mime !== 'application/json+workflow' || !instanceId) return false; + const p = payload as { files?: Array<{ id: string }> } | undefined; + const fileId = p?.files?.[0]?.id; + if (!fileId) return false; + try { + const result = await importWorkflowFromFile(request, instanceId, { fileId }); + await loadWorkflows(); + if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); + return true; + } catch (e) { + console.error(`${LOG} workflow drop import failed`, e); + return false; + } + }} /> {configurableSelected && selectedNode && ( diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index ae236ea..65c6743 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -143,6 +143,11 @@ interface FlowCanvasProps { getCategoryIcon: (category: string) => React.ReactNode; onSelectionChange?: (node: CanvasNode | null) => void; highlightedNodeIds?: Record; + /** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt + * (z. B. ``application/json+workflow`` aus der UDB-FilesTab), + * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen. + * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */ + onExternalDrop?: (mime: string, payload: unknown) => Promise | boolean; } const HIGHLIGHT_COLORS: Record = { @@ -162,6 +167,7 @@ export const FlowCanvas: React.FC = ({ nodes, getCategoryIcon, onSelectionChange, highlightedNodeIds, + onExternalDrop, }) => { const { t } = useLanguage(); const containerRef = useRef(null); @@ -256,8 +262,31 @@ export const FlowCanvas: React.FC = ({ nodes, }, [connections]); const handleDrop = useCallback( - (e: React.DragEvent) => { + async (e: React.DragEvent) => { e.preventDefault(); + // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) + if (onExternalDrop) { + const reservedMimes = new Set([ + 'application/json', + 'application/tree-items', + 'application/file-id', + 'application/file-ids', + 'application/folder-id', + ]); + for (const mime of Array.from(e.dataTransfer.types)) { + if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue; + const raw = e.dataTransfer.getData(mime); + if (!raw) continue; + try { + const payload = JSON.parse(raw); + const handled = await onExternalDrop(mime, payload); + if (handled) return; + } catch { + // andere Drag-Source → ignorieren, Standard-Pfad versuchen + } + } + } + // 2) Standard: Node-Type aus der NodeSidebar const raw = e.dataTransfer.getData('application/json'); if (!raw || !containerRef.current) return; try { @@ -269,7 +298,7 @@ export const FlowCanvas: React.FC = ({ nodes, onDropNodeType(type, Math.max(0, x), Math.max(0, y)); } catch (_) {} }, - [onDropNodeType, panOffset, zoom] + [onDropNodeType, onExternalDrop, panOffset, zoom] ); const handleHandleMouseDown = useCallback( diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index 7147502..48ab651 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -5,10 +5,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { CanvasNode } from './FlowCanvas'; -import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi'; +import type { NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowApi'; import { getLabel } from '../nodes/shared/utils'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; +import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -72,12 +73,21 @@ export const NodeConfigPanel: React.FC = ({ node, [onParametersChange] ); + const dataFlow = useAutomation2DataFlow(); + const portTypeCatalog: Record = (dataFlow?.portTypeCatalog as Record | undefined) ?? {}; + if (!node || !nodeType) return null; const isTrigger = node.type.startsWith('trigger.'); const showNameField = onNodeUpdate && !isTrigger; const parameters = nodeType.parameters || []; + const inputPortDefs = nodeType.inputPorts ?? {}; + const outputPortDefs = nodeType.outputPorts ?? {}; + const inputPortEntries = Object.entries(inputPortDefs); + const outputPortEntries = Object.entries(outputPortDefs); + const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0; + return (
{showNameField && ( @@ -101,6 +111,45 @@ export const NodeConfigPanel: React.FC = ({ node, {getLabel(nodeType.description, language)}

)} + {hasPortInfo && ( +
+ + {t('Datenfluss (Eingabe / Ausgabe)')} + + {inputPortEntries.length > 0 && ( +
+
+ {'\u2B07'} {t('Eingabe')} +
+ {inputPortEntries.map(([idx, def]) => ( + <_PortFieldList + key={`in-${idx}`} + portIndex={Number(idx)} + schemaNames={def?.accepts ?? []} + catalog={portTypeCatalog} + emptyLabel={t('keine Felder')} + /> + ))} +
+ )} + {outputPortEntries.length > 0 && ( +
+
+ {'\u2B06'} {t('Ausgabe')} +
+ {outputPortEntries.map(([idx, def]) => ( + <_PortFieldList + key={`out-${idx}`} + portIndex={Number(idx)} + schemaNames={def?.schema ? [def.schema] : []} + catalog={portTypeCatalog} + emptyLabel={t('keine Felder')} + /> + ))} +
+ )} +
+ )} {parameters.map((param: NodeTypeParameter) => { const frontendType = param.frontendType || 'text'; const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; @@ -120,3 +169,53 @@ export const NodeConfigPanel: React.FC = ({ node,
); }; + +interface _PortFieldListProps { + portIndex: number; + schemaNames: string[]; + catalog: Record; + emptyLabel: string; +} + +const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel }) => { + if (!schemaNames.length) return null; + return ( +
+
+ {`#${portIndex} `}{schemaNames.join(' | ')} +
+ {schemaNames.map((name) => { + const schema = catalog[name]; + const fields = schema?.fields ?? []; + if (name === 'Transit') { + return ( +
+ {'\u00B7 Transit (durchgereichte Daten)'} +
+ ); + } + if (!fields.length) { + return ( +
+ {`\u00B7 ${emptyLabel}`} +
+ ); + } + return ( +
    + {fields.map((f) => ( +
  • + {f.name} + {`: ${f.type}`} + {!f.required && {' (optional)'}} + {f.description && ( +
    {f.description}
    + )} +
  • + ))} +
+ ); + })} +
+ ); +}; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index d0db9f7..5d929f5 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -42,6 +42,16 @@ opacity: 0.5; } +/* Visueller Hint für Custom-Drag-Sources (z. B. Workflow-Files): + * pulst dezent beim Hover, um zu signalisieren "hier kann ich woanders hingezogen werden". */ +@keyframes _customDragPulse { + 0%, 100% { box-shadow: inset 0 0 0 0 transparent; } + 50% { box-shadow: inset 2px 0 0 0 var(--color-primary, #F25843); } +} +.treeNode.hasCustomDrag:hover { + animation: _customDragPulse 1.6s ease-in-out infinite; +} + .chevron { width: 12px; height: 12px; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 289b404..52b1096 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -17,6 +17,18 @@ import { usePrompt, type PromptOptions } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + type UdbSurface, + resolveActionLabel, +} from './actions/types'; +import { useFileActions, runAction, type ResolvedActions } from './actions/registry'; +import { useViewMode } from './actions/useViewMode'; +import { usePointerLongPress } from './actions/usePointerLongPress'; +import { FileActionContextMenu } from './actions/FileActionContextMenu'; +import { FileActionBottomSheet } from './actions/FileActionBottomSheet'; /* ── Public types ──────────────────────────────────────────────────────── */ @@ -80,6 +92,11 @@ export interface FolderTreeProps { onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Optionale Custom-Aktionen (Plugin-Slot, siehe `actions/types.ts`). + * Built-in Aktionen funktionieren auch ohne dieses Prop unverändert. */ + customActions?: FileAction[]; + /** Aufruf-Surface (z. B. ``'graphEditor'``) — wird in Predicates der Custom-Actions gespiegelt. */ + udbContext?: UdbSurface; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -148,6 +165,28 @@ function _computeFlatList( return result; } +function _matchesShortcut(e: KeyboardEvent, shortcut: string): boolean { + const parts = shortcut.toLowerCase().split('+').map(p => p.trim()); + const wantMod = parts.includes('mod'); + const wantShift = parts.includes('shift'); + const wantAlt = parts.includes('alt'); + const wantCtrl = parts.includes('ctrl') && !wantMod; + const key = parts.find(p => !['mod', 'shift', 'alt', 'ctrl'].includes(p)); + if (!key) return false; + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + const modOk = wantMod ? (isMac ? e.metaKey : e.ctrlKey) : true; + const ctrlOk = wantCtrl ? e.ctrlKey : (wantMod ? true : !e.ctrlKey); + const shiftOk = wantShift === e.shiftKey; + const altOk = wantAlt === e.altKey; + const keyOk = e.key.toLowerCase() === key || e.code.toLowerCase() === `key${key}`; + return modOk && ctrlOk && shiftOk && altOk && keyOk; +} + +function _windowConfirm(_title: string, body: string): boolean { + if (typeof window === 'undefined') return true; + return window.confirm(body); +} + function _fileIcon(mime?: string): string { if (!mime) return '\uD83D\uDCC4'; if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F'; @@ -186,6 +225,21 @@ interface SelectionCtx { onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Action-System Pipeline. Wenn vorhanden, rendert das FileItem zusätzlich + * Right-Click-Menu, Long-Press-Sheet und Custom-Inline-Icons. */ + actions?: { + actionCtx: FileActionContext; + /** Liefert pro Target die nach Kanal sortierten/gefilterten Aktionen. */ + resolveFor: (target: FileActionTarget) => ResolvedActions; + /** Öffnet das Right-Click-Menu am angegebenen Viewport-Punkt. */ + openMenu: (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => void; + /** Öffnet das Bottom-Sheet (Mobile Long-Press). */ + openSheet: (target: FileActionTarget, title?: string) => void; + /** Custom-Drag-MIME-Types, die zusätzlich ans dataTransfer gehängt werden. */ + applyDragPayload: (e: React.DragEvent, target: FileActionTarget) => void; + }; + /** Inline-Rename-Trigger des FolderTree (für die Built-in `core.rename`-Action). */ + registerInlineRename: (fileId: string, fn: () => void) => void; } /* ── Stable trio (chat | scope | neutralize) ────────────────────────────── @@ -276,6 +330,37 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { const isSelected = sel.selectedItemIds.has(file.id); const multiSelected = sel.selectedItemIds.size > 1; + const _beginRename = useCallback(() => { + setRenameValue(file.fileName); + setRenaming(true); + }, [file.fileName]); + useEffect(() => { + sel.registerInlineRename(file.id, _beginRename); + }, [file.id, _beginRename, sel]); + + const _buildActionTarget = useCallback((): FileActionTarget => { + return { files: [file], folders: [] }; + }, [file]); + + const _onContextMenu = useCallback((e: React.MouseEvent) => { + if (!sel.actions) return; + e.preventDefault(); + e.stopPropagation(); + sel.actions.openMenu({ x: e.clientX, y: e.clientY }, _buildActionTarget(), file.fileName); + }, [sel.actions, _buildActionTarget, file.fileName]); + + const _longPressHandlers = usePointerLongPress( + useCallback(() => { + if (!sel.actions) return; + sel.actions.openSheet(_buildActionTarget(), file.fileName); + }, [sel.actions, _buildActionTarget, file.fileName]), + ); + + const inlineCustomActions = useMemo(() => { + if (!sel.actions) return []; + return sel.actions.resolveFor(_buildActionTarget()).inline; + }, [sel.actions, _buildActionTarget]); + const _handleRename = useCallback(async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== file.fileName && sel.onRenameFile) { @@ -310,11 +395,15 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { styles.fileNode, isSelected ? styles.multiSelected : '', dragging ? styles.dragging : '', + inlineCustomActions.length > 0 ? styles.hasCustomDrag : '', ].filter(Boolean).join(' ')} onClick={(e) => sel.onItemClick(file.id, 'file', e)} + onContextMenu={_onContextMenu} + {..._longPressHandlers} draggable onDragStart={(e) => { sel.onItemDragStart(e, file.id, 'file', file.fileName); + if (sel.actions) sel.actions.applyDragPayload(e, _buildActionTarget()); setDragging(true); }} onDragEnd={() => setDragging(false)} @@ -345,8 +434,27 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { )} + {!multiSelected && inlineCustomActions.slice(0, 3).map((a) => { + const Icon = a.icon; + return ( + + ); + })} {sel.onRenameFile && !multiSelected && ( - )} @@ -674,8 +782,25 @@ export default function FolderTree({ onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, + customActions, udbContext, }: FolderTreeProps) { const { t } = useLanguage(); + const viewMode = useViewMode(); + const containerRef = useRef(null); + const inlineRenameRegistryRef = useRef void>>(new Map()); + const _registerInlineRename = useCallback((fileId: string, fn: () => void) => { + inlineRenameRegistryRef.current.set(fileId, fn); + }, []); + + const [menuState, setMenuState] = useState<{ + anchor: { x: number; y: number }; + target: FileActionTarget; + title?: string; + } | null>(null); + const [sheetState, setSheetState] = useState<{ + target: FileActionTarget; + title?: string; + } | null>(null); const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); @@ -799,6 +924,60 @@ export default function FolderTree({ return ids; }, [tree]); + const _beginInlineRename = useCallback((fileId: string) => { + const fn = inlineRenameRegistryRef.current.get(fileId); + if (fn) fn(); + }, []); + + const actionCtx: FileActionContext = useMemo(() => ({ + viewMode, + udbContext, + }), [viewMode, udbContext]); + + const fileActions = useFileActions(actionCtx, customActions, { + onRenameFile, + onDeleteFile, + onDeleteFiles, + onDeleteFolders, + onSendToChat, + t, + beginInlineRename: _beginInlineRename, + }); + + const _openMenu = useCallback( + (anchor: { x: number; y: number }, target: FileActionTarget, title?: string) => { + setMenuState({ anchor, target, title }); + }, + [], + ); + const _openSheet = useCallback((target: FileActionTarget, title?: string) => { + setSheetState({ target, title }); + }, []); + const _closeMenu = useCallback(() => setMenuState(null), []); + const _closeSheet = useCallback(() => setSheetState(null), []); + + const _applyDragPayload = useCallback( + (e: React.DragEvent, target: FileActionTarget) => { + const drag = fileActions.forTarget(target).drag; + for (const a of drag) { + if (!a.dragMime) continue; + try { + e.dataTransfer.setData( + a.dragMime, + JSON.stringify({ + actionId: a.id, + files: target.files.map((f) => ({ id: f.id, name: f.fileName })), + folders: target.folders.map((f) => ({ id: f.id, name: f.name })), + }), + ); + } catch { + // dataTransfer.setData kann in seltenen Fällen werfen (read-only) — nicht fatal. + } + } + }, + [fileActions], + ); + const sel: SelectionCtx = useMemo(() => { const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); @@ -815,8 +994,55 @@ export default function FolderTree({ onScopeChange, onNeutralizeToggle, onSendToChat, + actions: { + actionCtx, + resolveFor: fileActions.forTarget, + openMenu: _openMenu, + openSheet: _openSheet, + applyDragPayload: _applyDragPayload, + }, + registerInlineRename: _registerInlineRename, }; - }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]); + }, [ + selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, + onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, + onScopeChange, onNeutralizeToggle, onSendToChat, + actionCtx, fileActions.forTarget, _openMenu, _openSheet, _applyDragPayload, _registerInlineRename, + ]); + + // Tastenkürzel — nur dispatchen wenn FolderTree den Fokus enthält und es nicht aus + // einem Input/Editable-Element kommt (sonst kollidiert F2/Delete mit Inline-Rename). + useEffect(() => { + const _onKeyDown = (e: KeyboardEvent) => { + const root = containerRef.current; + if (!root) return; + const active = document.activeElement as HTMLElement | null; + if (!active || !root.contains(active)) return; + const tag = active.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || active.isContentEditable) return; + + const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id)); + const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id)); + if (selFileIds.length + selFolderIds.length === 0) return; + + const allFiles = (files ?? []).filter(f => selFileIds.includes(f.id)); + // Folder-Ziele für Shortcuts kommen aktuell nicht vor — Built-in `core.delete` + // operiert auf der Selection. Für diese Iteration genügt das. + const target: FileActionTarget = { files: allFiles, folders: [] }; + const resolved = fileActions.forTarget(target).shortcut; + + for (const a of resolved) { + if (!a.shortcut) continue; + if (_matchesShortcut(e, a.shortcut)) { + e.preventDefault(); + void runAction(a, target, actionCtx, _windowConfirm); + return; + } + } + }; + window.addEventListener('keydown', _onKeyDown); + return () => window.removeEventListener('keydown', _onKeyDown); + }, [selectedItemIds, allFileIds, allFolderIds, files, fileActions, actionCtx]); // Root drop handler: items dropped on the empty area go to root (null) const _handleRootDrop = useCallback(async (e: React.DragEvent) => { @@ -853,8 +1079,17 @@ export default function FolderTree({ onSelect(null); }, [_setSelection, onSelect]); + const menuActions = useMemo( + () => (menuState ? fileActions.forTarget(menuState.target).menu : []), + [menuState, fileActions], + ); + const sheetActions = useMemo( + () => (sheetState ? fileActions.forTarget(sheetState.target).sheet : []), + [sheetState, fileActions], + ); + return ( -
+
+ {menuState && ( + + )} +
); } diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.module.css b/src/components/FolderTree/actions/FileActionBottomSheet.module.css new file mode 100644 index 0000000..49f7d08 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionBottomSheet.module.css @@ -0,0 +1,103 @@ +/* Bottom-Sheet für FolderTree Long-Press (Mobile). */ + +@keyframes _slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes _fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.45); + animation: _fadeIn 0.15s ease-out; +} + +.sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1001; + background: var(--color-bg-elevated, #ffffff); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.18); + padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); + max-height: 80vh; + overflow-y: auto; + animation: _slideUp 0.18s ease-out; +} + +.handle { + width: 36px; + height: 4px; + border-radius: 2px; + background: var(--color-border, rgba(0, 0, 0, 0.18)); + margin: 4px auto 8px; +} + +.title { + padding: 4px 16px 12px; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary, #222); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.06)); + margin-bottom: 4px; +} + +.item { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 48px; + padding: 12px 16px; + border: none; + background: none; + cursor: pointer; + color: var(--color-text-primary, #222); + text-align: left; + font-size: 15px; + line-height: 1.2; +} + +.item:active { + background: var(--color-bg-hover, rgba(25, 118, 210, 0.10)); +} + +.item.danger { + color: var(--color-error, #d32f2f); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + font-size: 17px; + flex-shrink: 0; +} + +.label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--color-text-secondary, #999); + font-style: italic; +} diff --git a/src/components/FolderTree/actions/FileActionBottomSheet.tsx b/src/components/FolderTree/actions/FileActionBottomSheet.tsx new file mode 100644 index 0000000..efe5f62 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionBottomSheet.tsx @@ -0,0 +1,83 @@ +/** + * FileActionBottomSheet — Long-Press Action-Sheet für Mobile. + * + * Slide-Up von unten, 48 px Touch-Targets, ESC + Backdrop schließen. + */ + +import React, { useEffect } from 'react'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; +import { runAction } from './registry'; +import styles from './FileActionBottomSheet.module.css'; + +interface Props { + open: boolean; + actions: FileAction[]; + target: FileActionTarget; + ctx: FileActionContext; + onClose: () => void; + title?: string; + confirm?: (title: string, body: string) => boolean | Promise; +} + +export const FileActionBottomSheet: React.FC = ({ + open, + actions, + target, + ctx, + onClose, + title, + confirm, +}) => { + useEffect(() => { + if (!open) return; + const _onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', _onKey); + return () => window.removeEventListener('keydown', _onKey); + }, [open, onClose]); + + if (!open) return null; + + const _handleClick = async (action: FileAction) => { + onClose(); + await runAction(action, target, ctx, confirm); + }; + + return ( + <> +
+
+ + + ); +}; diff --git a/src/components/FolderTree/actions/FileActionContextMenu.module.css b/src/components/FolderTree/actions/FileActionContextMenu.module.css new file mode 100644 index 0000000..3307011 --- /dev/null +++ b/src/components/FolderTree/actions/FileActionContextMenu.module.css @@ -0,0 +1,103 @@ +/* Context-Menu für FolderTree (Right-Click). + * Floating, ARIA-menu, Backdrop-Click + ESC schließen. */ + +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + background: transparent; +} + +.menu { + position: fixed; + z-index: 1001; + min-width: 200px; + max-width: 320px; + background: var(--color-bg-elevated, #ffffff); + border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12)); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 4px 0; + font-size: 13px; + color: var(--color-text-primary, #222); + user-select: none; +} + +.header { + padding: 4px 12px 6px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-secondary, #888); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.divider { + height: 1px; + margin: 4px 0; + background: var(--color-border, rgba(0, 0, 0, 0.08)); +} + +.item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 12px; + border: none; + background: none; + cursor: pointer; + color: inherit; + text-align: left; + font: inherit; + line-height: 1.2; +} + +.item:hover, +.item:focus-visible { + background: var(--color-bg-hover, rgba(25, 118, 210, 0.08)); + outline: none; +} + +.item.danger { + color: var(--color-error, #d32f2f); +} + +.item.danger:hover, +.item.danger:focus-visible { + background: var(--color-bg-error, rgba(211, 47, 47, 0.08)); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + font-size: 13px; + flex-shrink: 0; +} + +.label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shortcut { + flex-shrink: 0; + font-size: 11px; + color: var(--color-text-secondary, #999); + font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace; + padding-left: 12px; +} + +.empty { + padding: 8px 12px; + font-size: 12px; + color: var(--color-text-secondary, #999); + font-style: italic; +} diff --git a/src/components/FolderTree/actions/FileActionContextMenu.tsx b/src/components/FolderTree/actions/FileActionContextMenu.tsx new file mode 100644 index 0000000..64e4a1c --- /dev/null +++ b/src/components/FolderTree/actions/FileActionContextMenu.tsx @@ -0,0 +1,146 @@ +/** + * FileActionContextMenu — Floating Right-Click-Menu für FolderTree. + * + * Wird vom FolderTree gemountet wenn `onContextMenu` auf einer Zeile feuert. + * Schließt sich bei Backdrop-Klick, ESC oder nach Aktion-Dispatch. + */ + +import React, { useEffect, useRef } from 'react'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; +import { runAction } from './registry'; +import styles from './FileActionContextMenu.module.css'; + +interface Props { + /** Sichtbar/positioniert. ``null`` → nicht gemountet. */ + anchor: { x: number; y: number } | null; + actions: FileAction[]; + target: FileActionTarget; + ctx: FileActionContext; + /** Wird aufgerufen sobald das Menü schließen soll (Backdrop, ESC, nach Action). */ + onClose: () => void; + /** Optional: Header-Label (z. B. Dateiname). */ + title?: string; + /** Optionaler Confirm-Provider (z. B. browser native ``window.confirm``). */ + confirm?: (title: string, body: string) => boolean | Promise; +} + +export const FileActionContextMenu: React.FC = ({ + anchor, + actions, + target, + ctx, + onClose, + title, + confirm, +}) => { + const menuRef = useRef(null); + + useEffect(() => { + if (!anchor) return; + const _onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', _onKey); + return () => window.removeEventListener('keydown', _onKey); + }, [anchor, onClose]); + + useEffect(() => { + if (!anchor || !menuRef.current) return; + menuRef.current.focus(); + }, [anchor]); + + if (!anchor) return null; + + const adjusted = _adjustToViewport(anchor, menuRef.current); + + const _handleClick = async (action: FileAction) => { + onClose(); + await runAction(action, target, ctx, confirm); + }; + + return ( + <> +
{ + e.preventDefault(); + onClose(); + }} + /> +
+ {title &&
{title}
} + {actions.length === 0 ? ( +
+ ) : ( + actions.map((a, idx) => { + const Icon = a.icon; + const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item; + return ( + + {idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && ( +
+ )} + + + ); + }) + )} +
+ + ); +}; + +function _adjustToViewport( + anchor: { x: number; y: number }, + menu: HTMLDivElement | null, +): { x: number; y: number } { + if (!menu) return anchor; + const rect = menu.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const margin = 4; + let x = anchor.x; + let y = anchor.y; + if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin); + if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin); + return { x, y }; +} + +function _formatShortcut(s: string): string { + const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); + return s + .split('+') + .map((part) => { + const k = part.trim().toLowerCase(); + if (k === 'mod') return isMac ? '\u2318' : 'Ctrl'; + if (k === 'shift') return isMac ? '\u21E7' : 'Shift'; + if (k === 'alt') return isMac ? '\u2325' : 'Alt'; + if (k === 'ctrl') return 'Ctrl'; + return k.length === 1 ? k.toUpperCase() : part; + }) + .join(isMac ? '' : '+'); +} diff --git a/src/components/FolderTree/actions/registry.ts b/src/components/FolderTree/actions/registry.ts new file mode 100644 index 0000000..0ba618c --- /dev/null +++ b/src/components/FolderTree/actions/registry.ts @@ -0,0 +1,218 @@ +/** + * useFileActions — zentraler Registry-Hook für FolderTree Aktionen. + * + * Liefert eine einheitliche, gefilterte und sortierte Aktion-Liste, die das + * `FolderTree`-Inneres an Right-Click-Menü, Long-Press-Sheet, Tastenkürzel und + * Drag-Source dispatched. Built-in-Aktionen (Rename, Delete, Send-to-Chat) + * werden aus den vorhandenen FolderTree-Callbacks abgeleitet, damit existierende + * Aufrufer nichts ändern müssen. + */ + +import { useMemo } from 'react'; +import { FaPen, FaTrash, FaCommentDots } from 'react-icons/fa'; +import { + type FileAction, + type FileActionContext, + type FileActionTarget, + resolveActionLabel, +} from './types'; + +/** Callback-Bündel mit den heutigen `FolderTreeProps`-Handlern. + * Optional, weil nicht jeder Aufrufer alle Built-ins anbietet. */ +export interface BuiltinCallbacks { + onRenameFile?: (fileId: string, newName: string) => Promise; + onDeleteFile?: (fileId: string) => Promise; + onDeleteFiles?: (fileIds: string[]) => Promise; + onDeleteFolders?: (folderIds: string[]) => Promise; + onSendToChat?: ( + items: Array<{ id: string; type: 'file' | 'folder'; name: string }>, + ) => void; + /** Translator (i18n) — typischerweise `t` aus dem LanguageContext. */ + t?: (key: string, vars?: Record) => string; + /** Inline-Rename-Trigger (Eingabefeld in der Zeile). Wird vom FolderTree + * intern bereitgestellt — nicht vom Aufrufer. */ + beginInlineRename?: (fileId: string) => void; +} + +/** Sortierte, gefilterte Aktionsliste pro Kanal. */ +export interface ResolvedActions { + inline: FileAction[]; + menu: FileAction[]; + sheet: FileAction[]; + shortcut: FileAction[]; + drag: FileAction[]; +} + +const _IDENTITY: BuiltinCallbacks['t'] = (s) => s; + +/** Built-in-Definitionen, die aus den heute hartcodierten Callbacks abgeleitet werden. + * Diese erscheinen NUR in den neuen Kanälen (Menu, Sheet, Shortcut) — die Inline-Icons + * werden weiterhin direkt vom FolderTree-Renderer gezeichnet, damit die bestehende + * "Stable-Trio + dynamische Aktionen"-Logik unangetastet bleibt. */ +function _buildBuiltins(cb: BuiltinCallbacks): FileAction[] { + const t = cb.t ?? _IDENTITY; + const list: FileAction[] = []; + + if (cb.onSendToChat) { + list.push({ + id: 'core.sendToChat', + label: t('In Chat senden'), + icon: FaCommentDots, + scope: 'multi', + channels: ['menu', 'sheet'], + sortOrder: 100, + handler: ({ files, folders }) => { + const items = [ + ...files.map((f) => ({ id: f.id, type: 'file' as const, name: f.fileName })), + ...folders.map((f) => ({ id: f.id, type: 'folder' as const, name: f.name })), + ]; + if (items.length > 0) cb.onSendToChat!(items); + }, + }); + } + + if (cb.onRenameFile && cb.beginInlineRename) { + list.push({ + id: 'core.rename', + label: t('Umbenennen'), + icon: FaPen, + scope: 'file', + channels: ['menu', 'sheet', 'shortcut'], + shortcut: 'F2', + sortOrder: 110, + predicate: ({ files, folders }) => files.length === 1 && folders.length === 0, + handler: ({ files }) => { + if (files.length === 1) cb.beginInlineRename!(files[0].id); + }, + }); + } + + if (cb.onDeleteFile || cb.onDeleteFiles || cb.onDeleteFolders) { + list.push({ + id: 'core.delete', + label: ({ files, folders }) => + files.length + folders.length > 1 + ? t('{count} Einträge löschen', { count: String(files.length + folders.length) }) + : t('Löschen'), + icon: FaTrash, + scope: 'multi', + channels: ['menu', 'sheet', 'shortcut'], + shortcut: 'Delete', + danger: true, + sortOrder: 200, + predicate: ({ files, folders }) => files.length > 0 || folders.length > 0, + confirm: { + title: t('Löschen bestätigen'), + body: ({ files, folders }) => + files.length + folders.length > 1 + ? t('{count} Einträge löschen?', { + count: String(files.length + folders.length), + }) + : t('Diesen Eintrag löschen?'), + }, + handler: async ({ files, folders }) => { + if (folders.length > 0 && cb.onDeleteFolders) { + await cb.onDeleteFolders(folders.map((f) => f.id)); + } + if (files.length > 1 && cb.onDeleteFiles) { + await cb.onDeleteFiles(files.map((f) => f.id)); + } else if (files.length === 1) { + if (cb.onDeleteFile) await cb.onDeleteFile(files[0].id); + else if (cb.onDeleteFiles) await cb.onDeleteFiles([files[0].id]); + } + }, + }); + } + + return list; +} + +/** + * Zentrale Registry-Hook. + * + * @param ctx Aktueller Aufruf-Kontext (View-Mode, Mandant, …). + * @param customs Vom Aufrufer registrierte Custom-Actions (Plugin-Slot). + * @param builtins Callback-Bündel der Built-in-Aktionen (aus FolderTreeProps abgeleitet). + * + * Die Rückgabe ist memoized und pro Kanal vorgefiltert; ein `Predicate`-Check + * pro Target erfolgt zusätzlich erst beim Render der jeweiligen Zeile/Sheet. + */ +export function useFileActions( + ctx: FileActionContext, + customs: FileAction[] | undefined, + builtins: BuiltinCallbacks, +): { + /** Alle Aktionen (gemerged + sortiert), unfiltered nach Predicate. */ + all: FileAction[]; + /** Liefert die für ein konkretes Target sichtbaren Aktionen, gruppiert nach Kanal. */ + forTarget: (target: FileActionTarget) => ResolvedActions; +} { + const all = useMemo(() => { + const merged = [..._buildBuiltins(builtins), ...(customs ?? [])]; + merged.sort( + (a, b) => + (a.sortOrder ?? 1000) - (b.sortOrder ?? 1000) || a.id.localeCompare(b.id), + ); + return merged; + // We intentionally depend on each callback identity so re-renders pick up + // updated handlers (closures over instanceId etc.). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + customs, + builtins.onRenameFile, + builtins.onDeleteFile, + builtins.onDeleteFiles, + builtins.onDeleteFolders, + builtins.onSendToChat, + builtins.beginInlineRename, + builtins.t, + ]); + + const forTarget = useMemo(() => { + return (target: FileActionTarget): ResolvedActions => { + const _matches = (a: FileAction): boolean => { + if (a.scope === 'file' && (target.files.length !== 1 || target.folders.length > 0)) + return false; + if (a.scope === 'folder' && (target.folders.length !== 1 || target.files.length > 0)) + return false; + if (a.scope === 'multi' && target.files.length + target.folders.length === 0) + return false; + if (a.predicate && !a.predicate(target, ctx)) return false; + return true; + }; + + const matched = all.filter(_matches); + return { + inline: matched.filter((a) => a.channels.includes('inline')), + menu: matched.filter((a) => a.channels.includes('menu')), + sheet: matched.filter((a) => a.channels.includes('sheet')), + shortcut: matched.filter((a) => a.channels.includes('shortcut')), + drag: matched.filter((a) => a.channels.includes('drop')), + }; + }; + }, [all, ctx]); + + return { all, forTarget }; +} + +/** Hilfs-Dispatcher: führt Confirm + Handler aus, fängt Fehler ab und loggt sie. + * Der eigentliche Confirm-Dialog wird vom Renderer (Context-Menu/Sheet) bereitgestellt + * — dieser Helper bleibt UI-frei und ist von außerhalb React aufrufbar. */ +export async function runAction( + action: FileAction, + target: FileActionTarget, + ctx: FileActionContext, + confirmFn?: (label: string, body: string) => boolean | Promise, +): Promise { + if (action.confirm && confirmFn) { + const ok = await confirmFn(action.confirm.title, action.confirm.body(target)); + if (!ok) return; + } + try { + await action.handler(target, ctx); + } catch (err) { + console.error(`[FileAction] ${action.id} failed`, err); + } +} + +export { resolveActionLabel }; diff --git a/src/components/FolderTree/actions/types.ts b/src/components/FolderTree/actions/types.ts new file mode 100644 index 0000000..d2b75db --- /dev/null +++ b/src/components/FolderTree/actions/types.ts @@ -0,0 +1,87 @@ +/** + * Action-Modell für FolderTree (UDB Action System). + * + * Eine `FileAction` ist die kanonische Beschreibung einer Aktion, die der User + * auf eine Datei oder einen Ordner anwenden kann. Dieselbe Definition rendert + * sich automatisch in mehreren Kanälen: + * - inline → Icon-Button am rechten Zeilenrand + * - menu → Eintrag im Right-Click-Context-Menu + * - sheet → Eintrag im Long-Press Bottom-Sheet (Mobile) + * - shortcut → Tastenkürzel solange FolderTree Fokus hat + * - drop → Drag-Source: hängt eine zusätzliche MIME ans dataTransfer + * + * Vorhandene Built-in-Aktionen (Rename, Delete, Send-to-Chat) bleiben hinter + * dem System bestehen; wenn der Aufrufer keine `customActions` mitliefert, + * verhält sich `FolderTree` 1:1 wie zuvor. + */ + +import type React from 'react'; +import type { FileNode, FolderNode } from '../FolderTree'; + +export type FileActionScope = 'file' | 'folder' | 'multi'; +export type FileActionChannel = 'inline' | 'menu' | 'sheet' | 'shortcut' | 'drop'; + +/** UDB-Aufruf-Kontext — Aufrufer-Sites identifizieren sich, damit Predicates + * pro Surface entscheiden können (z. B. "nur im Graph-Editor sichtbar"). */ +export type UdbSurface = + | 'workspace' + | 'graphEditor' + | 'trustee' + | 'standalone' + | 'sharepoint'; + +export interface FileActionContext { + mandateId?: string; + featureInstanceId?: string; + viewMode: 'desktop' | 'mobile'; + udbContext?: UdbSurface; +} + +export interface FileActionTarget { + files: FileNode[]; + folders: FolderNode[]; +} + +export interface FileActionConfirm { + title: string; + body: (target: FileActionTarget) => string; +} + +export interface FileAction { + /** Global eindeutige Aktion-ID, namespace-prefixed (z. B. ``workflow.openInEditor``). */ + id: string; + /** Anzeige-Label (statisch oder als Funktion vom Target abgeleitet). */ + label: string | ((target: FileActionTarget) => string); + /** Icon-Komponente (react-icons-Style), bekommt optional `size`-Prop. */ + icon: React.ComponentType<{ size?: number }>; + /** Optionale Tönung des Icons (CSS color string). */ + iconColor?: string; + /** Was ist das Target — einzelne Datei, Ordner, oder Mehrfach-Selektion. */ + scope: FileActionScope; + /** Über welche UI-Kanäle wird die Aktion angeboten. */ + channels: FileActionChannel[]; + /** Pure, billig — entscheidet ob die Aktion für das aktuelle Target sichtbar ist. */ + predicate?: (target: FileActionTarget, ctx: FileActionContext) => boolean; + /** Async oder sync. Fehler werden vom Renderer geloggt; Toasts macht der Aufrufer. */ + handler: (target: FileActionTarget, ctx: FileActionContext) => Promise | void; + /** Tastenkürzel, z. B. `mod+e`. ``mod`` = Cmd auf Mac, Ctrl sonst. */ + shortcut?: string; + /** Wenn gesetzt → Bestätigungs-Dialog vor `handler`. */ + confirm?: FileActionConfirm; + /** MIME-Type für Drag-Source: wird zusätzlich ans `dataTransfer` gehängt. */ + dragMime?: string; + /** Sortier-Reihenfolge — kleinere Werte zuerst (Built-ins liegen bei 100, 110, 120…). */ + sortOrder?: number; + /** Visuell als gefährliche/destruktive Aktion markieren (rote Tönung). */ + danger?: boolean; +} + +/** Resolver-Helper: liest das Label eines `FileAction` aus, egal ob String oder Funktion. */ +export function resolveActionLabel(action: FileAction, target: FileActionTarget): string { + return typeof action.label === 'function' ? action.label(target) : action.label; +} + +/** Hilfs-Konstruktor: baut ein leeres Target. */ +export function emptyTarget(): FileActionTarget { + return { files: [], folders: [] }; +} diff --git a/src/components/FolderTree/actions/usePointerLongPress.ts b/src/components/FolderTree/actions/usePointerLongPress.ts new file mode 100644 index 0000000..18722c9 --- /dev/null +++ b/src/components/FolderTree/actions/usePointerLongPress.ts @@ -0,0 +1,75 @@ +import { useCallback, useRef } from 'react'; + +/** + * Long-Press-Erkennung über Pointer-Events. + * + * Liefert Handler die direkt auf `
` etc. gespreaded werden können. + * Ein "Long-Press" feuert nach `thresholdMs` (Default 500 ms) wenn der Pointer + * sich nicht weiter als `moveTolerance` Pixel bewegt hat. + */ + +interface LongPressOptions { + thresholdMs?: number; + moveTolerance?: number; + /** Wenn ``true``, werden auch Maus-Events behandelt (für Desktop-Smoke-Tests). */ + includeMouse?: boolean; +} + +interface LongPressHandlers { + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: (e: React.PointerEvent) => void; + onPointerCancel: (e: React.PointerEvent) => void; + onPointerLeave: (e: React.PointerEvent) => void; +} + +export function usePointerLongPress( + callback: (e: React.PointerEvent) => void, + options: LongPressOptions = {}, +): LongPressHandlers { + const { thresholdMs = 500, moveTolerance = 8, includeMouse = false } = options; + const timerRef = useRef(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + const firedRef = useRef(false); + + const _clear = useCallback(() => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + startPosRef.current = null; + firedRef.current = false; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!includeMouse && e.pointerType === 'mouse') return; + _clear(); + startPosRef.current = { x: e.clientX, y: e.clientY }; + firedRef.current = false; + timerRef.current = window.setTimeout(() => { + firedRef.current = true; + callback(e); + }, thresholdMs); + }, + [callback, includeMouse, thresholdMs, _clear], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (timerRef.current === null || !startPosRef.current) return; + const dx = e.clientX - startPosRef.current.x; + const dy = e.clientY - startPosRef.current.y; + if (Math.abs(dx) > moveTolerance || Math.abs(dy) > moveTolerance) _clear(); + }, + [moveTolerance, _clear], + ); + + return { + onPointerDown, + onPointerMove, + onPointerUp: _clear, + onPointerCancel: _clear, + onPointerLeave: _clear, + }; +} diff --git a/src/components/FolderTree/actions/useViewMode.ts b/src/components/FolderTree/actions/useViewMode.ts new file mode 100644 index 0000000..187b832 --- /dev/null +++ b/src/components/FolderTree/actions/useViewMode.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +/** + * Liefert den aktuellen View-Mode (`'desktop' | 'mobile'`) basierend auf + * Viewport-Breite + Touch-Heuristik. Mobile = Breite < 768 px ODER + * Touch-Primary-Pointer ohne Maus. + */ +export function useViewMode(): 'desktop' | 'mobile' { + const [mode, setMode] = useState<'desktop' | 'mobile'>(() => _detect()); + + useEffect(() => { + const _onResize = () => setMode(_detect()); + window.addEventListener('resize', _onResize); + return () => window.removeEventListener('resize', _onResize); + }, []); + + return mode; +} + +function _detect(): 'desktop' | 'mobile' { + if (typeof window === 'undefined') return 'desktop'; + const isNarrow = window.matchMedia('(max-width: 768px)').matches; + const isCoarse = window.matchMedia('(pointer: coarse)').matches; + return isNarrow || isCoarse ? 'mobile' : 'desktop'; +} diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 732330b..49f8e58 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -65,6 +65,7 @@ import { CustomActionButton } from '../ActionButtons'; import { formatUnixTimestamp } from '../../../utils/time'; +import { applyFrontendFormat } from '../../../utils/applyFrontendFormat'; import { FormGeneratorControls } from '../FormGeneratorControls'; import { CopyableTruncatedValue } from '../../UiComponents/CopyableTruncatedValue'; import { @@ -112,6 +113,13 @@ export interface ColumnConfig { cellClassName?: (value: any, row: any) => string; // For custom cell styling fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/") fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel") + // Backend-provided render hints (gateway/.../attributeUtils.py). + // Excel-style format string applied by ``applyFrontendFormat`` to numeric/int + // values, e.g. "R:#'###.00", "M:b" (bytes), "L:0.000". Empty = default rendering. + frontendFormat?: string; + // Pre-translated label tokens for binary/categorical cells, e.g. ["Ja", "-", "Nein"]. + // Resolved server-side via i18n so the FE never needs another translation hop. + frontendFormatLabels?: string[]; } export interface FormGeneratorTableProps { @@ -1721,6 +1729,17 @@ export function FormGeneratorTable>({ return '-'; } + // Backend render hints take priority for binary cells when explicit + // ``frontendFormatLabels`` are provided -- this is how the LLM/user + // overrides the default ✓/✗ tri-state with meaningful labels like + // ["Ja", "-", "Nein"] or ["aktiv", "?", "inaktiv"]. We still defer to the + // inline-editable boolean renderer when no labels are configured so the + // existing checkbox UX is preserved. + if (column.frontendFormatLabels && (typeof value === 'boolean' || (column.type && isCheckboxType(column.type)))) { + const formatted = applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage); + return formatted.text; + } + // Handle boolean/checkbox fields with inline editing support if (column.type && isCheckboxType(column.type)) { return renderBooleanCell(value, column, row); @@ -1894,6 +1913,15 @@ export function FormGeneratorTable>({ case 'boolean': return value ? '✓' : '✗'; case 'number': + case 'float': + case 'integer': + case 'int': + // Honor backend ``frontendFormat`` (e.g. "R:#'###.00", "M:b") if present. + // Without a format hint we keep the existing default locale rendering so + // existing tables continue to look the same. + if (column.frontendFormat || column.frontendFormatLabels) { + return applyFrontendFormat(value, column.frontendFormat, column.frontendFormatLabels, column.type, currentLanguage).text; + } return typeof value === 'number' ? value.toLocaleString() : value; default: return String(value); @@ -2427,9 +2455,17 @@ export function FormGeneratorTable>({ const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; + const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; + const alignStyle: React.CSSProperties = formatAlign === 'R' + ? { textAlign: 'right' } + : formatAlign === 'M' + ? { textAlign: 'center' } + : formatAlign === 'L' + ? { textAlign: 'left' } + : isNumeric ? { textAlign: 'right' } : {}; return ( + style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}> {formatCellValue(cellValue, column, row)} ); @@ -2543,9 +2579,19 @@ export function FormGeneratorTable>({ const customClassName = column.cellClassName ? column.cellClassName(cellValue, row) : ''; const combinedClassName = `${styles.td} ${customClassName}`.trim(); const isNumeric = column.type === 'number' || column.type === 'float' || column.type === 'integer'; + // ``frontendFormat`` may carry an explicit alignment prefix + // ("L:", "M:", "R:") that overrides the numeric default. + const formatAlign = column.frontendFormat && column.frontendFormat[1] === ':' ? column.frontendFormat[0] : ''; + const alignStyle: React.CSSProperties = formatAlign === 'R' + ? { textAlign: 'right' } + : formatAlign === 'M' + ? { textAlign: 'center' } + : formatAlign === 'L' + ? { textAlign: 'left' } + : isNumeric ? { textAlign: 'right' } : {}; return ( + style={{ width: columnWidths[column.key] || column.width || 150, minWidth: columnWidths[column.key] || column.width || 150, maxWidth: columnWidths[column.key] || column.width || 150, ...alignStyle }}> {formatCellValue(cellValue, column, row)} ); diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 09618f0..08bd0fa 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,9 +1,17 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; +import { FaFileImport } from 'react-icons/fa'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import FolderTree from '../../components/FolderTree/FolderTree'; import type { FileNode } from '../../components/FolderTree/FolderTree'; +import type { FileAction } from '../../components/FolderTree/actions/types'; import { useFileContext } from '../../contexts/FileContext'; +import { useApiRequest } from '../../hooks/useApi'; +import { + importWorkflowFromFile, + WORKFLOW_FILE_EXTENSION, +} from '../../api/workflowApi'; +import { useToast } from '../../contexts/ToastContext'; import styles from './FilesTab.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; @@ -11,10 +19,16 @@ interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string, fileName?: string) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in + * den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste, + * Auto-Select) bleibt Aufgabe des Aufrufers. */ + onWorkflowImported?: (workflowId: string) => void; } -const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat }) => { +const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => { const { t } = useLanguage(); + const { request } = useApiRequest(); + const { showSuccess, showError } = useToast(); const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); @@ -179,6 +193,48 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [refreshFolders, refreshTreeFiles]); + const _customActions: FileAction[] = useMemo(() => { + if (context.surface !== 'graphEditor') return []; + return [ + { + id: 'workflow.openInEditor', + label: t('In Graph-Editor laden'), + icon: FaFileImport, + scope: 'file', + channels: ['inline', 'menu', 'sheet', 'drop'], + dragMime: 'application/json+workflow', + sortOrder: 50, + predicate: ({ files }) => + files.length === 1 && + files[0].fileName.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION), + handler: async ({ files }) => { + const file = files[0]; + if (!context.instanceId || !file) return; + try { + const result = await importWorkflowFromFile(request, context.instanceId, { + fileId: file.id, + }); + const warnings = result?.warnings ?? []; + const wfId = result?.workflow?.id; + if (warnings.length > 0) { + showSuccess( + t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { + n: String(warnings.length), + }), + ); + } else { + showSuccess(t('Workflow importiert (deaktiviert).')); + } + if (wfId && onWorkflowImported) onWorkflowImported(wfId); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + showError(t('Import fehlgeschlagen: {msg}', { msg })); + } + }, + }, + ]; + }, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]); + const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { try { await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); @@ -282,6 +338,8 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat onFolderScopeChange={_onFolderScopeChange} onFolderNeutralizeToggle={_onFolderNeutralizeToggle} onSendToChat={onSendToChat} + customActions={_customActions} + udbContext={context.surface} /> {_fileNodes.length === 0 && ( diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 66aaf1f..6254f51 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1161,7 +1161,9 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, + // Compensate the 3px borderLeft on active rows with -3px paddingLeft so + // the row content stays at exactly the same x-position as inactive rows. + paddingLeft: (depth * 16 + 4) - (ds ? 3 : 0), paddingRight: 4, paddingTop: 3, paddingBottom: 3, @@ -1406,7 +1408,10 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => { }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + // Compensate the 3px borderLeft on active wildcard rows with -3px + // paddingLeft so the row content stays at the same x-position. + paddingLeft: wildcardFds ? 1 : 4, + paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, background: wildcardFds ? (hovered ? '#ede7f6' : '#7b1fa208') @@ -1585,6 +1590,7 @@ interface _GroupFolderViewProps extends _FeatureActionContext { const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const segments = [...pathSegments, `g:${objectKey}`]; @@ -1592,17 +1598,45 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { const expanded = ctx.featureExpandedPaths.has(pathKey); const chevron = expanded ? '\u25BE' : '\u25B8'; + // Container-wildcard objectKey: matches every record/table inside this group. + // Pattern lives in the backend workspaceContext-resolver -- the trailing `.*` + // is treated as a glob-prefix so a single FDS row drives chat/scope/neutralize + // for every child without having to add each one individually. + const containerObjectKey = `data.feature.${featureNode.featureCode}.group:${objectKey}.*`; + const wildcardFds = ctx.featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId && f.objectKey === containerObjectKey, + ); + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + objectKey: containerObjectKey, + label, + }; + return (
ctx.onToggleFeaturePath(pathKey)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', label); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + // Compensate the 3px border on active wildcard rows so the row + // content stays at the same x-position whether or not it's active. + paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), + paddingRight: 4, paddingTop: 3, paddingBottom: 3, cursor: 'pointer', borderRadius: 3, - background: 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', }} > @@ -1617,6 +1651,52 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { }}> {label} + + + +
{expanded && items.length > 0 && ( @@ -1672,17 +1752,45 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { } }; + // Container-wildcard objectKey for the parent group: matches every record in + // ``table`` so a single FDS row drives chat/scope/neutralize for the whole list. + const containerObjectKey = `data.feature.${featureNode.featureCode}.${table.tableName}.*`; + const wildcardFds = ctx.featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId + && f.tableName === table.tableName + && !f.recordFilter + && f.objectKey === containerObjectKey, + ); + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + tableName: table.tableName, + objectKey: containerObjectKey, + label: table.label || table.tableName, + }; + return (
setHovered(true)} onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + e.dataTransfer.setData('application/feature-source', JSON.stringify(_chatPayload)); + e.dataTransfer.setData('text/plain', _chatPayload.label); + e.dataTransfer.effectAllowed = 'copy'; + }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + paddingLeft: (depth * 16 + 4) - (wildcardFds ? 3 : 0), + 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', }} > @@ -1701,6 +1809,54 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { +{childTables.length} {t('Tabellen')} )} + + + +
{expanded && records && records.length > 0 && ( diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index b872405..421f5d7 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -7,11 +7,23 @@ import styles from './UnifiedDataBar.module.css'; export type UdbTab = 'chats' | 'files' | 'sources'; +/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext` + * weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich + * pro Surface registrieren können. */ +export type UdbSurface = + | 'workspace' + | 'graphEditor' + | 'trustee' + | 'standalone' + | 'sharepoint'; + export interface UdbContext { instanceId: string; mandateId?: string; featureInstanceId?: string; userId?: string; + /** Optionales Surface-Tag, hilft Custom-Actions zu entscheiden, wann sie sichtbar sind. */ + surface?: UdbSurface; } export interface AddToChat_FileItem { @@ -44,6 +56,9 @@ interface UnifiedDataBarProps { onSendToChat_Files?: (items: AddToChat_FileItem[]) => void; onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void; onAttachDataSource?: (dsId: string) => void; + /** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den + * Graph-Editor importiert wurde (Action `workflow.openInEditor`). */ + onWorkflowImportedFromFile?: (workflowId: string) => void; className?: string; } @@ -72,6 +87,7 @@ const UnifiedDataBar: React.FC = ({ onSendToChat_Files, onSendToChat_FeatureSource, onAttachDataSource, + onWorkflowImportedFromFile, className, }) => { const { t } = useLanguage(); @@ -116,6 +132,7 @@ const UnifiedDataBar: React.FC = ({ context={context} onFileSelect={onFileSelect} onSendToChat={onSendToChat_Files} + onWorkflowImported={onWorkflowImportedFromFile} /> )} {currentTab === 'sources' && !hideTabs?.includes('sources') && ( diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx index c886561..5022b21 100644 --- a/src/hooks/useConfirm.tsx +++ b/src/hooks/useConfirm.tsx @@ -66,12 +66,19 @@ export function useConfirm() { return (
{ + if (e.key === 'Escape') _handleCancel(); + }} + tabIndex={-1} >
e.stopPropagation()} diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx index 3324ef3..60d2621 100644 --- a/src/hooks/usePrompt.tsx +++ b/src/hooks/usePrompt.tsx @@ -73,12 +73,19 @@ export function usePrompt() { return (
{ + if (e.key === 'Escape') _handleCancel(); + }} + tabIndex={-1} >
e.stopPropagation()} diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index a3218eb..0baa236 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -69,6 +69,8 @@ export const ConnectionsPage: React.FC = () => { maxWidth: attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, }; if (attr.name === 'userId') { diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 79d6199..78f3cbb 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -212,6 +212,8 @@ export const FilesPage: React.FC = () => { maxWidth: attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, })); cols.push({ key: 'sysCreatedBy', diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 38ea58f..ff359d0 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -85,6 +85,8 @@ export const PromptsPage: React.FC = () => { maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, + frontendFormat: (attr as any).frontendFormat, + frontendFormatLabels: (attr as any).frontendFormatLabels, })); // Add sysCreatedBy column with FK resolution to show username diff --git a/src/pages/views/trustee/TrusteeDataTablesView.tsx b/src/pages/views/trustee/TrusteeDataTablesView.tsx index cf86671..4c70226 100644 --- a/src/pages/views/trustee/TrusteeDataTablesView.tsx +++ b/src/pages/views/trustee/TrusteeDataTablesView.tsx @@ -66,6 +66,13 @@ interface TabDef { Wrapper: React.FC<{ instanceId: string }>; } +interface TabGroupDef { + id: string; + label: string; + color: string; + tabs: TabDef[]; +} + function _buildApiEndpoint(instanceId: string, suffix: string): string { return `/api/trustee/${instanceId}/${suffix}`; } @@ -136,21 +143,53 @@ const _DataAccountBalancesWrapper = _makeReadOnlyWrapper(useTrusteeDataAccountBa const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs'); const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs'); -function _buildTabs(t: (k: string) => string): TabDef[] { +// Group structure mirrors `DATA_OBJECTS` in `gateway/modules/features/trustee/mainTrustee.py` +// (UDB folders): Stammdaten · Lokale Daten · Konfiguration · Daten aus Buchhaltungssystem. +// "Stammdaten" is page-only (Organisation/Rolle/Zugriff/Vertrag are admin tables that +// don't appear in the UDB because the feature instance IS the organisation). +function _buildTabGroups(t: (k: string) => string): TabGroupDef[] { return [ - { id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper }, - { id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper }, - { id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper }, - { id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper }, - { id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper }, - { id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper }, - { id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Konten (Sync)'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper }, - { id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen (Sync)'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper }, - { id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen (Sync)'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper }, - { id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte (Sync)'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper }, - { id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden (Sync)'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper }, - { id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Konfiguration'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper }, - { id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Buchhaltungs-Synchronisation'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper }, + { + id: 'master', + label: t('Stammdaten'), + color: '#1976d2', + tabs: [ + { id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper }, + { id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper }, + { id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper }, + { id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper }, + ], + }, + { + id: 'localData', + label: t('Lokale Daten'), + color: '#388e3c', + tabs: [ + { id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper }, + { id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper }, + ], + }, + { + id: 'config', + label: t('Konfiguration'), + color: '#5e35b1', + tabs: [ + { id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Verbindung'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper }, + { id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Sync-Protokoll'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper }, + ], + }, + { + id: 'accountingData', + label: t('Daten aus Buchhaltungssystem'), + color: '#ef6c00', + tabs: [ + { id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Kontenplan'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper }, + { id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper }, + { id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper }, + { id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper }, + { id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper }, + ], + }, ]; } @@ -163,16 +202,16 @@ export const TrusteeDataTablesView: React.FC = () => { const instanceId = useInstanceId(); const [searchParams, setSearchParams] = useSearchParams(); - const tabs = useMemo(() => _buildTabs(t), [t]); - const visibleTabs = tabs; + const tabGroups = useMemo(() => _buildTabGroups(t), [t]); + const visibleTabs = useMemo(() => tabGroups.flatMap((g) => g.tabs), [tabGroups]); const requestedTab = searchParams.get('tab'); const activeTab = useMemo(() => { if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) { return requestedTab; } - return visibleTabs[0]?.id || tabs[0].id; - }, [requestedTab, visibleTabs, tabs]); + return visibleTabs[0]?.id || ''; + }, [requestedTab, visibleTabs]); const _setActiveTab = useCallback((tabId: string) => { setSearchParams({ tab: tabId }, { replace: true }); @@ -217,47 +256,84 @@ export const TrusteeDataTablesView: React.FC = () => {
- {visibleTabs.map((tab) => ( - +
+ {group.label} +
+
+ {group.tabs.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+
))}
diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx index 04735f5..874e74a 100644 --- a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx +++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx @@ -131,6 +131,8 @@ export const TrusteeDataTab: React.FC = ({ maxWidth: attr.maxWidth || 400, fkSource: attr.fkSource, fkDisplayField: attr.fkDisplayField, + frontendFormat: attr.frontendFormat, + frontendFormatLabels: attr.frontendFormatLabels, })); }, [attributes, hiddenColumns]); diff --git a/src/utils/applyFrontendFormat.ts b/src/utils/applyFrontendFormat.ts new file mode 100644 index 0000000..5e41e7a --- /dev/null +++ b/src/utils/applyFrontendFormat.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Patrick Motsch +// All rights reserved. +// +// Central frontend formatter for backend ``frontend_format`` / ``frontend_format_labels`` +// hints (see gateway/modules/shared/attributeUtils.py). Applied by FormGeneratorTable +// for numeric, int and binary cells. Pure function so it can be unit-tested in isolation. +// +// Format string syntax (Excel-inspired, stays simple on purpose): +// : +// ALIGN ∈ { L, M, R } -- left / middle / right alignment hint +// PATTERN may contain literal text wrapped in @...@ (e.g. "@CHF@ #'###.00") +// +// Numeric patterns: +// - "#'###.00" Swiss thousands separator + 2 decimals 1'444'555.67 +// - "0.000" Force 3 decimals, no thousands separator 4.556 +// - "0" Integer, no decimals 12 +// - "b" Auto-scale Byte units (B/KB/MB/GB/TB) 12.3 MB +// - "@CHF@ #'###.00" → "CHF 1'234.50" (literal text via @...@) +// +// Binary (boolean) values use ``frontendFormatLabels`` as a 3-tuple +// [trueLabel, neutralLabel, falseLabel]. ``neutralLabel`` is rendered for +// ``null``/``undefined`` -- pass "" or "-" if you want to hide it. + +export type RenderAlign = 'left' | 'right' | 'center'; + +export interface AppliedFormat { + /** Display string ready for the cell. */ + text: string; + /** Alignment hint for the cell, if the format specified one. */ + align?: RenderAlign; +} + +const _ALIGN_MAP: Record = { + L: 'left', + M: 'center', + R: 'right', +}; + +const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + +/** + * Split "ALIGN:PATTERN" into its parts. Returns ``[alignChar, pattern]``, + * with ``alignChar`` being ``""`` if no align prefix is present. + */ +function _splitAlign(format: string): [string, string] { + if (format.length >= 2 && format[1] === ':' && _ALIGN_MAP[format[0]] !== undefined) { + return [format[0], format.slice(2)]; + } + return ['', format]; +} + +/** + * Extract the literal-text segment ``@…@`` if present, returning + * ``[prefix, numericPattern, suffix]``. The literal segment is dropped from + * the numeric pattern so the rest can be parsed as a number format. Only the + * first literal block is recognised (good enough for ``@CHF@ #'###.00`` and + * ``#'###.00 @CHF@`` cases). + */ +function _extractLiteral(pattern: string): { prefix: string; numericPattern: string; suffix: string } { + const match = pattern.match(/^([^@]*)@([^@]*)@(.*)$/); + if (!match) { + return { prefix: '', numericPattern: pattern, suffix: '' }; + } + const [, before, literal, after] = match; + if (!after.trim() && before.trim()) { + return { prefix: '', numericPattern: before.trim(), suffix: literal }; + } + return { prefix: literal, numericPattern: after.trim() || before.trim(), suffix: '' }; +} + +/** + * Format ``value`` as bytes auto-scaled to the largest unit ``< 1024``. + * Locale-formatted to one decimal for KB+, integer for raw B. + */ +function _formatBytes(value: number, locale: string): string { + const sign = value < 0 ? '-' : ''; + let abs = Math.abs(value); + let unitIdx = 0; + while (abs >= 1024 && unitIdx < _BYTE_UNITS.length - 1) { + abs /= 1024; + unitIdx += 1; + } + const decimals = unitIdx === 0 ? 0 : 1; + const formatted = abs.toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + return `${sign}${formatted} ${_BYTE_UNITS[unitIdx]}`; +} + +/** + * Format a numeric value following ``pattern``. Supported patterns: + * - "b" byte units + * - "#'###.00" thousands separator + N decimals (digits after the dot) + * - "0.000" N decimals, no thousands separator + * - "0" integer + * Falls back to ``toLocaleString`` for unknown patterns so we never break the cell. + */ +function _formatNumeric(value: number, pattern: string, locale: string): string { + if (!pattern) return value.toLocaleString(locale); + if (pattern === 'b' || pattern === 'B') return _formatBytes(value, locale); + const decimalsMatch = pattern.match(/[.,](0+)\s*$/); + const decimals = decimalsMatch ? decimalsMatch[1].length : 0; + const useThousands = pattern.includes("'") || pattern.includes('#'); + return value.toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: useThousands, + }); +} + +/** + * Apply backend render hints to an arbitrary value. + * + * - ``type === 'binary'`` (or boolean value) renders the i18n-resolved label + * tuple from ``formatLabels``. + * - Numeric/int values are formatted by ``_formatNumeric`` according to + * the ``ALIGN:PATTERN`` format string. + * - If ``format`` is empty, the value is rendered with ``toLocaleString`` for + * numbers and ``String(value)`` for everything else (no format == no change). + */ +export function applyFrontendFormat( + value: unknown, + format: string | undefined, + formatLabels: string[] | undefined, + type: string | undefined, + locale: string = 'de-CH', +): AppliedFormat { + const [alignChar, pattern] = format ? _splitAlign(format) : ['', '']; + const align = _ALIGN_MAP[alignChar]; + + // Boolean / binary rendering with i18n-resolved labels + if (type === 'binary' || type === 'boolean' || typeof value === 'boolean') { + if (value === null || value === undefined) { + const neutral = formatLabels && formatLabels.length >= 2 ? formatLabels[1] : '-'; + return { text: neutral, align }; + } + if (formatLabels && formatLabels.length >= 1) { + const trueLabel = formatLabels[0] ?? ''; + const falseLabel = formatLabels[2] ?? formatLabels[formatLabels.length - 1] ?? ''; + return { text: value ? trueLabel : falseLabel, align }; + } + return { text: value ? '✓' : '✗', align }; + } + + if (value === null || value === undefined) { + return { text: '-', align }; + } + + const numeric = typeof value === 'number' + ? value + : (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value)) + ? Number(value) + : NaN); + + if (Number.isFinite(numeric) && (type === 'number' || type === 'float' || type === 'integer' || type === 'int' || pattern || typeof value === 'number')) { + if (!pattern) { + return { text: numeric.toLocaleString(locale), align }; + } + const { prefix, numericPattern, suffix } = _extractLiteral(pattern); + const numStr = _formatNumeric(numeric, numericPattern, locale); + const text = `${prefix ? `${prefix} ` : ''}${numStr}${suffix ? ` ${suffix}` : ''}`.trim(); + return { text, align }; + } + + return { text: String(value), align }; +} + +/** + * Convenience: returns just the formatted string. Use this when you only + * need the text (e.g. CSV export) and the alignment is irrelevant. + */ +export function applyFrontendFormatText( + value: unknown, + format: string | undefined, + formatLabels: string[] | undefined, + type: string | undefined, + locale: string = 'de-CH', +): string { + return applyFrontendFormat(value, format, formatLabels, type, locale).text; +}