diff --git a/src/api.ts b/src/api.ts index 1a5f2b8..69ac956 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ // api.ts import axios from 'axios'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; -import { clearUserDataCache } from './utils/userCache'; +import { clearUserDataCache, getUserDataCache } from './utils/userCache'; // Utility function to resolve hostname to IP address const resolveHostnameToIP = async (hostname: string): Promise => { @@ -85,6 +85,13 @@ api.interceptors.request.use( console.log('πŸͺ Using httpOnly cookies for authentication (automatic)'); } + // Send app language to backend so i18n labels match the UI + const userData = getUserDataCache(); + const appLanguage = userData?.language || navigator.language.split('-')[0] || 'de'; + if (config.headers) { + config.headers['Accept-Language'] = appLanguage; + } + // Add multi-tenant context headers from URL (if not already set) // This ensures Feature-Instance roles are loaded for permission checks const context = getContextFromUrl(); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index b079d6a..4fd3dfe 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -208,6 +208,7 @@ export interface FolderInfo { id: string; name: string; parentId: string | null; + fileCount?: number; mandateId?: string; featureInstanceId?: string; createdAt?: number; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 5fb25bc..71f31c8 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -24,6 +24,7 @@ export interface FolderNode { id: string; name: string; parentId: string | null; + fileCount?: number; children?: FolderNode[]; } @@ -363,7 +364,7 @@ function _TreeNode({ const isNavSelected = selectedFolderId === node.id; const isMultiSelected = sel.selectedItemIds.has(node.id); const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : []; - const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0; + const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0 || (node.fileCount ?? 0) > 0; useEffect(() => { if (renaming && inputRef.current) inputRef.current.focus(); @@ -609,7 +610,7 @@ export default function FolderTree({ if (next.has(id)) next.delete(id); else next.add(id); return next; }); - }, []); + }, [onToggleExpand]); const _setSelection = useCallback((ids: Set) => { if (onSelectionChange) { diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index 112b7f7..c22552c 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -141,9 +141,7 @@ padding: 10px 12px; text-align: left; font-weight: 600; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; + font-size: 13px; color: var(--color-text-secondary, #64748b); white-space: nowrap; overflow: visible; diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 9f39c33..eaa5df0 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -223,11 +223,13 @@ function FilterValuesList({ allValues, activeFilter, onSelect, + resolveLabel, }: { columnKey: string; allValues: string[]; activeFilter: any; onSelect: (value: string) => void; + resolveLabel?: (value: string) => string; }) { const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE); const sentinelRef = useRef(null); @@ -256,16 +258,19 @@ function FilterValuesList({ return ( <> - {visibleValues.map(value => ( -
onSelect(value)} - title={value} - > - {value.length > 30 ? value.substring(0, 30) + '...' : value} -
- ))} + {visibleValues.map(value => { + const label = resolveLabel ? resolveLabel(value) : value; + return ( +
onSelect(value)} + title={label} + > + {label.length > 30 ? label.substring(0, 30) + '...' : label} +
+ ); + })} {displayCount < allValues.length && (
)} @@ -884,6 +889,9 @@ export function FormGeneratorTable>({ // Skip if column has static filterOptions (enum) – those are used directly if (column?.filterOptions && column.filterOptions.length > 0) return; + // FK columns: extract values from actual data instead of backend endpoint + if (column?.fkSource) return; + // Skip if already loaded or currently loading if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; @@ -932,6 +940,22 @@ export function FormGeneratorTable>({ return column.filterOptions; } + // FK columns: extract distinct values from actual data (Excel autofilter style) + if (column?.fkSource) { + const seen = new Set(); + data.forEach(row => { + const val = row[columnKey]; + if (val && typeof val === 'string' && val.trim()) { + seen.add(val); + } + }); + return Array.from(seen).sort((a, b) => { + const labelA = fkCache[column.fkSource!]?.[a] || a; + const labelB = fkCache[column.fkSource!]?.[b] || b; + return labelA.localeCompare(labelB); + }); + } + if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) { return asyncFilterValues[columnKey]; } @@ -945,7 +969,7 @@ export function FormGeneratorTable>({ ); } return []; - }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]); + }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, fkCache]); // Close filter dropdown when clicking outside useEffect(() => { @@ -2012,6 +2036,7 @@ export function FormGeneratorTable>({ allValues={getUniqueValuesForColumn(column.key)} activeFilter={filters[column.key]} onSelect={(value) => handleFilter(column.key, value)} + resolveLabel={column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined} /> )} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 2c8da10..90ade97 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,24 +1,12 @@ -import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useMemo } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import FolderTree from '../../components/FolderTree/FolderTree'; import type { FileNode } from '../../components/FolderTree/FolderTree'; import { useFileContext } from '../../contexts/FileContext'; import styles from './FilesTab.module.css'; - import { useLanguage } from '../../providers/language/LanguageContext'; -interface FileEntry { - id: string; - fileName: string; - mimeType?: string; - fileSize?: number; - folderId?: string | null; - tags?: string[]; - scope: string; - neutralize: boolean; -} - interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string, fileName?: string) => void; @@ -26,8 +14,6 @@ interface FilesTabProps { const FilesTab: React.FC = ({ context, onFileSelect }) => { const { t } = useLanguage(); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); @@ -37,6 +23,11 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { const { folders, refreshFolders, + treeFileNodes, + treeFilesLoading, + refreshTreeFiles, + expandedFolderIds, + toggleFolderExpanded, handleCreateFolder, handleRenameFolder, handleDeleteFolder, @@ -46,85 +37,32 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { handleMoveFiles: contextMoveFiles, handleFileDelete, handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, } = useFileContext(); - const _loadFiles = useCallback(async () => { - setLoading(true); - try { - const response = await api.get(`/api/workspace/${context.instanceId}/files`); - const body = response.data; - const rawList = - (Array.isArray(body?.files) && body.files) || - (Array.isArray(body?.data) && body.data) || - (Array.isArray(body) ? body : []); - setFiles( - rawList.map((f: any) => ({ - id: f.id, - fileName: f.fileName || f.name || 'unknown', - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - tags: f.tags || [], - scope: f.scope || 'personal', - neutralize: f.neutralize || false, - })), - ); - } catch (err) { - console.error('Failed to load files:', err); - } finally { - setLoading(false); - } - }, [context.instanceId]); - - useEffect(() => { - _loadFiles(); - }, [_loadFiles]); - - useEffect(() => { - const _onFileUploaded = () => { - setTimeout(() => _loadFiles(), 150); - }; - window.addEventListener('fileUploaded', _onFileUploaded as EventListener); - return () => window.removeEventListener('fileUploaded', _onFileUploaded as EventListener); - }, [_loadFiles]); - const _folderNodes = useMemo(() => folders.map(f => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, + fileCount: f.fileCount ?? 0, })), [folders], ); const _fileNodes: FileNode[] = useMemo(() => { - let result = files; + let result = treeFileNodes; if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); result = result.filter(f => - f.fileName.toLowerCase().includes(q) - || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), + f.fileName.toLowerCase().includes(q), ); } - return result - .sort((a, b) => a.fileName.localeCompare(b.fileName)) - .map(f => ({ - id: f.id, - fileName: f.fileName, - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - scope: f.scope, - neutralize: f.neutralize, - })); - }, [files, searchQuery]); + return result; + }, [treeFileNodes, searchQuery]); - const _refreshAll = useCallback(() => { - _loadFiles(); - refreshFolders(); - }, [_loadFiles, refreshFolders]); + const _refreshAll = useCallback(async () => { + await Promise.all([refreshTreeFiles(), refreshFolders()]); + }, [refreshTreeFiles, refreshFolders]); const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { if (!context.instanceId || uploading) return; @@ -138,7 +76,7 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { headers: { 'Content-Type': 'multipart/form-data' }, }); } - _refreshAll(); + await _refreshAll(); } catch (err) { console.error('File upload failed:', err); } finally { @@ -178,62 +116,57 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { await handleMoveFile(fileId, targetFolderId); - _loadFiles(); - }, [handleMoveFile, _loadFiles]); + }, [handleMoveFile]); const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { await contextMoveFiles(fileIds, targetFolderId); - _loadFiles(); - }, [contextMoveFiles, _loadFiles]); + }, [contextMoveFiles]); const _onDeleteFolder = useCallback(async (folderId: string) => { await handleDeleteFolder(folderId); if (selectedFolderId === folderId) setSelectedFolderId(null); - _loadFiles(); - }, [handleDeleteFolder, selectedFolderId, _loadFiles]); + }, [handleDeleteFolder, selectedFolderId]); const _onRenameFile = useCallback(async (fileId: string, newName: string) => { await api.put(`/api/files/${fileId}`, { fileName: newName }); - _loadFiles(); - }, [_loadFiles]); + await refreshTreeFiles(); + }, [refreshTreeFiles]); const _onDeleteFile = useCallback(async (fileId: string) => { await handleFileDelete(fileId); - _loadFiles(); - }, [handleFileDelete, _loadFiles]); + }, [handleFileDelete]); const _onDeleteFiles = useCallback(async (fileIds: string[]) => { await api.post('/api/files/batch-delete', { fileIds }); - _loadFiles(); - }, [_loadFiles]); + await Promise.all([refreshTreeFiles(), refreshFolders()]); + }, [refreshTreeFiles, refreshFolders]); const _onDeleteFolders = useCallback(async (folderIds: string[]) => { await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - refreshFolders(); - _loadFiles(); - }, [refreshFolders, _loadFiles]); + await Promise.all([refreshFolders(), refreshTreeFiles()]); + }, [refreshFolders, refreshTreeFiles]); const _onScopeChange = useCallback(async (fileId: string, newScope: string) => { - setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f))); try { await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); + await refreshTreeFiles(); } catch (err) { console.error('Failed to update scope:', err); - _loadFiles(); } - }, [_loadFiles]); + }, [refreshTreeFiles]); const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { - setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f))); try { await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); + await refreshTreeFiles(); } catch (err) { console.error('Failed to toggle neutralize:', err); - _loadFiles(); } - }, [_loadFiles]); + }, [refreshTreeFiles]); - if (loading) return
{t('Dateien laden')}
; + if (treeFilesLoading && treeFileNodes.length === 0) { + return
{t('Dateien laden')}
; + } return (
= ({ context, onFileSelect }) => { selectedFolderId={selectedFolderId} onSelect={setSelectedFolderId} onFileSelect={onFileSelect ? (fileId: string) => { - const file = files.find(f => f.id === fileId); + const file = treeFileNodes.find(f => f.id === fileId); onFileSelect(fileId, file?.fileName); } : undefined} expandedIds={expandedFolderIds} diff --git a/src/contexts/FileContext.tsx b/src/contexts/FileContext.tsx index 1c53379..d148a6e 100644 --- a/src/contexts/FileContext.tsx +++ b/src/contexts/FileContext.tsx @@ -1,42 +1,60 @@ -import React, { createContext, useContext, useCallback, useState, useEffect } from 'react'; +import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; import api from '../api'; -import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles'; +import { useFileOperations } from '../hooks/useFiles'; import type { FolderInfo } from '../api/fileApi'; +import type { FileNode } from '../components/FolderTree/FolderTree'; export type { FolderInfo }; interface FileContextType { - files: UserFile[]; - loading: boolean; - error: string | null; - refetch: () => Promise; - handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>; + folders: FolderInfo[]; + foldersLoading: boolean; + refreshFolders: () => Promise; + + treeFileNodes: FileNode[]; + treeFilesLoading: boolean; + loadTreeFiles: (folderId: string) => Promise; + refreshTreeFiles: () => Promise; + + expandedFolderIds: Set; + toggleFolderExpanded: (id: string) => void; + + handleCreateFolder: (name: string, parentId: string | null) => Promise; + handleRenameFolder: (folderId: string, newName: string) => Promise; + handleDeleteFolder: (folderId: string) => Promise; + handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise; + handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise; + handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise; + handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; + 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 }>; handleFileDownload: (fileId: string, fileName: string) => Promise; uploadingFile: boolean; deletingFiles: Set; previewingFiles: Set; downloadingFiles: Set; - folders: FolderInfo[]; - foldersLoading: boolean; - refreshFolders: () => Promise; - handleCreateFolder: (name: string, parentId: string | null) => Promise; - handleRenameFolder: (folderId: string, newName: string) => Promise; - handleDeleteFolder: (folderId: string) => Promise; - handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise; - handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise; - handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise; - handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise; - handleDownloadFolder: (folderId: string, folderName: string) => Promise; - expandedFolderIds: Set; - toggleFolderExpanded: (id: string) => void; } export const FileContext = createContext(undefined); +const _ROOT_KEY = '__root__'; + +function _toFileNode(f: any): FileNode { + return { + id: f.id, + fileName: f.fileName || f.name || 'unknown', + mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + scope: f.scope, + neutralize: f.neutralize, + }; +} + export function FileProvider({ children }: { children: React.ReactNode }) { - const { data: files, loading, error, refetch: refetchFiles, removeFileOptimistically } = useUserFiles(); const { handleFileUpload: hookHandleFileUpload, handleFileDelete: hookHandleFileDelete, @@ -45,30 +63,33 @@ export function FileProvider({ children }: { children: React.ReactNode }) { uploadingFile, deletingFiles, previewingFiles, - downloadingFiles + downloadingFiles, } = useFileOperations(); - useEffect(() => { refetchFiles(); }, []); + // ── Derive a session-scoped storage key from the current feature-instance URL ── + const location = useLocation(); + const storageKey = useMemo(() => { + const match = location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); + const instanceId = match ? match[3] : '_global'; + return `folderTree-expandedIds-${instanceId}`; + }, [location.pathname]); - // ── Folder expanded state (persisted in localStorage) ─────────────────── - const _STORAGE_KEY = 'folderTree-expandedIds'; + // ── Folder expanded state (persisted per feature-instance in sessionStorage) ── const [expandedFolderIds, setExpandedFolderIds] = useState>(() => { try { - const stored = localStorage.getItem(_STORAGE_KEY); + const stored = sessionStorage.getItem(storageKey); return stored ? new Set(JSON.parse(stored)) : new Set(); } catch { return new Set(); } }); - const toggleFolderExpanded = useCallback((id: string) => { - setExpandedFolderIds(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); - try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {} - return next; - }); - }, []); + useEffect(() => { + try { + const stored = sessionStorage.getItem(storageKey); + setExpandedFolderIds(stored ? new Set(JSON.parse(stored)) : new Set()); + } catch { setExpandedFolderIds(new Set()); } + }, [storageKey]); - // ── Folder state (single source of truth) ────────────────────────────── + // ── Folder state ────────────────────────────────────────────────────── const [folders, setFolders] = useState([]); const [foldersLoading, setFoldersLoading] = useState(false); @@ -87,6 +108,92 @@ export function FileProvider({ children }: { children: React.ReactNode }) { useEffect(() => { refreshFolders(); }, [refreshFolders]); + // ── Tree files: lazy-loaded per expanded folder ─────────────────────── + const [treeFilesMap, setTreeFilesMap] = useState>(new Map()); + const [treeFilesLoading, setTreeFilesLoading] = useState(false); + + const loadTreeFiles = useCallback(async (folderId: string) => { + const key = folderId || _ROOT_KEY; + setTreeFilesLoading(true); + try { + const filterValue = folderId || null; + const resp = await api.get('/api/files/list', { + params: { + pagination: JSON.stringify({ + page: 1, + pageSize: 500, + filters: { folderId: filterValue }, + }), + }, + }); + const items: any[] = resp.data?.items || []; + setTreeFilesMap(prev => { + const next = new Map(prev); + next.set(key, items.map(_toFileNode)); + return next; + }); + } catch (err) { + console.error(`Failed to load tree files for folder ${folderId}:`, err); + } finally { + setTreeFilesLoading(false); + } + }, []); + + const _removeTreeFiles = useCallback((folderId: string) => { + const key = folderId || _ROOT_KEY; + setTreeFilesMap(prev => { + const next = new Map(prev); + next.delete(key); + return next; + }); + }, []); + + const refreshTreeFiles = useCallback(async () => { + const keys = Array.from(treeFilesMap.keys()); + await Promise.all( + keys.map(key => loadTreeFiles(key === _ROOT_KEY ? '' : key)), + ); + }, [treeFilesMap, loadTreeFiles]); + + // Load root files on mount + useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles]); + + // Load files for initially expanded folders + useEffect(() => { + expandedFolderIds.forEach(id => { + if (!treeFilesMap.has(id)) { + loadTreeFiles(id); + } + }); + // Only on mount – don't re-run when treeFilesMap changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const treeFileNodes: FileNode[] = useMemo(() => { + const result: FileNode[] = []; + for (const [, files] of treeFilesMap) { + for (const f of files) result.push(f); + } + return result; + }, [treeFilesMap]); + + // ── Toggle expand: load/unload tree files ───────────────────────────── + const toggleFolderExpanded = useCallback((id: string) => { + setExpandedFolderIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + _removeTreeFiles(id); + } else { + next.add(id); + loadTreeFiles(id); + } + try { sessionStorage.setItem(storageKey, JSON.stringify([...next])); } catch {} + return next; + }); + }, [storageKey, loadTreeFiles, _removeTreeFiles]); + + // ── Folder operations ───────────────────────────────────────────────── const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => { await api.post('/api/files/folders', { name, parentId: parentId || null }); await refreshFolders(); @@ -99,30 +206,53 @@ export function FileProvider({ children }: { children: React.ReactNode }) { const handleDeleteFolder = useCallback(async (folderId: string) => { await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } }); + _removeTreeFiles(folderId); await refreshFolders(); - await refetchFiles(); - }, [refreshFolders, refetchFiles]); + }, [refreshFolders, _removeTreeFiles]); const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); await refreshFolders(); }, [refreshFolders]); - const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await api.post(`/api/files/${fileId}/move`, { targetFolderId }); - await refetchFiles(); - }, [refetchFiles]); - - const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await api.post('/api/files/batch-move', { fileIds, targetFolderId }); - await refetchFiles(); - }, [refetchFiles]); - const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => { await api.post('/api/files/batch-move', { folderIds, targetParentId }); await refreshFolders(); }, [refreshFolders]); + // ── File operations ─────────────────────────────────────────────────── + const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await api.post(`/api/files/${fileId}/move`, { targetFolderId }); + await refreshTreeFiles(); + await refreshFolders(); + }, [refreshTreeFiles, refreshFolders]); + + const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await api.post('/api/files/batch-move', { fileIds, targetFolderId }); + await refreshTreeFiles(); + await refreshFolders(); + }, [refreshTreeFiles, refreshFolders]); + + const handleFileUpload = useCallback(async (file: File, workflowId?: string) => { + const result = await hookHandleFileUpload(file, workflowId); + if (result.success) { + await refreshTreeFiles(); + await refreshFolders(); + } + return result; + }, [hookHandleFileUpload, refreshTreeFiles, refreshFolders]); + + const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => { + const success = await hookHandleFileDelete(fileId, () => { + onOptimisticDelete?.(); + }); + if (success) { + await refreshTreeFiles(); + await refreshFolders(); + } + return success; + }, [hookHandleFileDelete, refreshTreeFiles, refreshFolders]); + const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => { try { const response = await api.get(`/api/files/folders/${folderId}/download`, { @@ -141,40 +271,28 @@ export function FileProvider({ children }: { children: React.ReactNode }) { } }, []); - // ── File operations ──────────────────────────────────────────────────── - - const handleFileUpload = useCallback(async (file: File, workflowId?: string) => { - const result = await hookHandleFileUpload(file, workflowId); - if (result.success && result.fileData) { - await refetchFiles(); - } - return result; - }, [hookHandleFileUpload, refetchFiles]); - - const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => { - const success = await hookHandleFileDelete(fileId, () => { - removeFileOptimistically(fileId); - onOptimisticDelete?.(); - }); - if (success) { - await refetchFiles(); - } - return success; - }, [hookHandleFileDelete, removeFileOptimistically, refetchFiles]); - - const refetch = useCallback(async () => { - await refetchFiles(); - }, [refetchFiles]); - return ( { await handleFileDownload(fileId, fileName); @@ -183,19 +301,6 @@ export function FileProvider({ children }: { children: React.ReactNode }) { deletingFiles, previewingFiles, downloadingFiles, - folders, - foldersLoading, - refreshFolders, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFile, - handleMoveFiles, - handleMoveFolders, - handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, }} > {children} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index d6916d8..7acd112 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -2,7 +2,8 @@ * FilesPage * * Split-view file management: FolderTree on the left, FormGeneratorTable on the right. - * Uses useResizablePanels for the divider. + * The tree is the master – it dictates which folder's files the table shows (paginated). + * Tree files are managed by FileContext (lazy-loaded per expanded folder). */ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; @@ -12,13 +13,11 @@ import { useFileContext } from '../../contexts/FileContext'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import FolderTree from '../../components/FolderTree/FolderTree'; -import type { FileNode } from '../../components/FolderTree/FolderTree'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; - import { useLanguage } from '../../providers/language/LanguageContext'; interface UserFile { @@ -32,8 +31,7 @@ interface UserFile { } export const FilesPage: React.FC = () => { - const { t } = useLanguage(); - + const { t } = useLanguage(); const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); const { prompt: promptInput, PromptDialog } = usePrompt(); @@ -48,13 +46,14 @@ export const FilesPage: React.FC = () => { maxLeftWidth: 40, }); + // ── Table data (paginated, filtered by selectedFolderId) ────────────── const { - data: files, + data: tableFiles, attributes, permissions, - loading, + loading: tableLoading, error, - refetch, + refetch: tableRefetch, pagination, fetchFileById, updateFileOptimistically, @@ -74,19 +73,22 @@ export const FilesPage: React.FC = () => { previewingFiles, } = useFileOperations(); + // ── Tree data (from FileContext – lazy-loaded per expanded folder) ───── const { folders, refreshFolders, + treeFileNodes, + refreshTreeFiles, + expandedFolderIds, + toggleFolderExpanded, handleCreateFolder, handleRenameFolder, handleDeleteFolder, handleMoveFolder, handleMoveFolders, - handleMoveFile, + handleMoveFile: contextMoveFile, handleMoveFiles: contextMoveFiles, handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, } = useFileContext(); const [editingFile, setEditingFile] = useState(null); @@ -94,43 +96,37 @@ export const FilesPage: React.FC = () => { const [treeSelectedIds, setTreeSelectedIds] = useState>(new Set()); const [highlightedFileId, setHighlightedFileId] = useState(null); - useEffect(() => { refetch(); }, []); + // ── Table refetch: always includes folderId filter ──────────────────── + const _tableRefetch = useCallback(async (params?: any) => { + const nextParams = { ...(params || {}) }; + const nextFilters = { ...(nextParams.filters || {}) }; + nextFilters.folderId = selectedFolderId; + nextParams.filters = nextFilters; + await tableRefetch(nextParams); + }, [tableRefetch, selectedFolderId]); - const treeFileNodes: FileNode[] = useMemo(() => { - if (!files) return []; - return files.map((f: UserFile) => ({ + useEffect(() => { + _tableRefetch({ page: 1, pageSize: 25 }); + }, [selectedFolderId, _tableRefetch]); + + const _refreshAll = useCallback(async () => { + await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + }, [_tableRefetch, refreshTreeFiles, refreshFolders]); + + // ── Folder nodes for tree (with fileCount) ──────────────────────────── + const folderNodes = useMemo(() => + folders.map(f => ({ id: f.id, - fileName: f.fileName, - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - })); - }, [files]); - - const _handleTreeFileSelect = useCallback((fileId: string) => { - const file = files?.find((f: UserFile) => f.id === fileId); - if (file) { - setSelectedFolderId(file.folderId ?? null); - setHighlightedFileId(fileId); - requestAnimationFrame(() => { - const row = document.querySelector('tr[data-highlighted="true"]'); - if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - setTimeout(() => setHighlightedFileId(null), 2500); - } - }, [files]); - - const filteredFiles = useMemo(() => { - if (!files) return []; - if (selectedFolderId === null) { - return files.filter((f: UserFile) => !f.folderId); - } - return files.filter((f: UserFile) => f.folderId === selectedFolderId); - }, [files, selectedFolderId]); + name: f.name, + parentId: f.parentId ?? null, + fileCount: f.fileCount ?? 0, + })), + [folders], + ); + // ── Columns ─────────────────────────────────────────────────────────── const columns = useMemo(() => { - const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId']; - + const hiddenColumns = ['id', 'fileHash', 'folderId']; const cols = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => ({ @@ -146,7 +142,6 @@ export const FilesPage: React.FC = () => { fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, })); - cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), @@ -157,8 +152,9 @@ export const FilesPage: React.FC = () => { width: 150, minWidth: 100, maxWidth: 250, + fkSource: '/api/users/', + fkDisplayField: 'username', } as any); - return cols; }, [attributes, t]); @@ -166,6 +162,51 @@ export const FilesPage: React.FC = () => { const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; + // ── Tree event handlers ─────────────────────────────────────────────── + const _handleTreeFileSelect = useCallback((fileId: string) => { + const file = treeFileNodes.find(f => f.id === fileId); + if (file) { + setSelectedFolderId(file.folderId ?? null); + setHighlightedFileId(fileId); + requestAnimationFrame(() => { + const row = document.querySelector('tr[data-highlighted="true"]'); + if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + setTimeout(() => setHighlightedFileId(null), 2500); + } + }, [treeFileNodes]); + + const _handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await contextMoveFile(fileId, targetFolderId); + await _tableRefetch(); + }, [contextMoveFile, _tableRefetch]); + + const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await contextMoveFiles(fileIds, targetFolderId); + await _tableRefetch(); + }, [contextMoveFiles, _tableRefetch]); + + const _handleRenameFile = useCallback(async (fileId: string, newName: string) => { + await handleFileUpdate(fileId, { fileName: newName }); + await Promise.all([_tableRefetch(), refreshTreeFiles()]); + }, [handleFileUpdate, _tableRefetch, refreshTreeFiles]); + + const _handleDeleteTreeFile = useCallback(async (fileId: string) => { + await handleFileDelete(fileId); + await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + }, [handleFileDelete, _tableRefetch, refreshTreeFiles, refreshFolders]); + + const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => { + await handleFileDeleteMultiple(fileIds); + await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + }, [handleFileDeleteMultiple, _tableRefetch, refreshTreeFiles, refreshFolders]); + + const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => { + await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); + await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); + }, [refreshFolders, refreshTreeFiles, _tableRefetch]); + + // ── Table event handlers ────────────────────────────────────────────── const handleEditClick = async (file: UserFile) => { const fullFile = await fetchFileById(file.id); if (fullFile) setEditingFile(fullFile as UserFile); @@ -178,19 +219,19 @@ export const FilesPage: React.FC = () => { }, editingFile); if (result.success) { setEditingFile(null); - refetch(); + await Promise.all([_tableRefetch(), refreshTreeFiles()]); } }; const handleDelete = async (file: UserFile) => { const success = await handleFileDelete(file.id); - if (success) refetch(); + if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); }; const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const ids = filesToDelete.map(f => f.id); const success = await handleFileDeleteMultiple(ids); - if (success) refetch(); + if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); }; const handleDownload = async (file: UserFile) => { @@ -207,16 +248,16 @@ export const FilesPage: React.FC = () => { const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleFileSelect = async (e: React.ChangeEvent) => { - const selectedFiles = e.target.files; - if (selectedFiles && selectedFiles.length > 0) { + const picked = e.target.files; + if (picked && picked.length > 0) { let successCount = 0; let errorCount = 0; - for (const file of Array.from(selectedFiles)) { + for (const file of Array.from(picked)) { const result = await handleFileUpload(file); if (result?.success) successCount++; else errorCount++; } if (fileInputRef.current) fileInputRef.current.value = ''; - await refetch(); + await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); if (successCount > 0) { showSuccess( t('Upload erfolgreich'), @@ -240,62 +281,13 @@ export const FilesPage: React.FC = () => { const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); if (isInSelection && selectedFiles.length > 1) { - const ids = selectedFiles.map(f => f.id); - e.dataTransfer.setData('application/file-ids', JSON.stringify(ids)); + e.dataTransfer.setData('application/file-ids', JSON.stringify(selectedFiles.map(f => f.id))); } else { e.dataTransfer.setData('application/file-id', row.id); } e.dataTransfer.effectAllowed = 'move'; }, [selectedFiles]); - const _handleMoveFilePage = useCallback(async (fileId: string, targetFolderId: string | null) => { - await handleMoveFile(fileId, targetFolderId); - await refetch(); - }, [handleMoveFile, refetch]); - - const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - await refetch(); - }, [contextMoveFiles, refetch]); - - const _handleRenameFile = useCallback(async (fileId: string, newName: string) => { - await handleFileUpdate(fileId, { fileName: newName }); - await refetch(); - }, [handleFileUpdate, refetch]); - - const _handleDeleteTreeFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - await refetch(); - }, [handleFileDelete, refetch]); - - const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => { - await handleFileDeleteMultiple(fileIds); - await refetch(); - }, [handleFileDeleteMultiple, refetch]); - - const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - await refreshFolders(); - await refetch(); - }, [refreshFolders, refetch]); - - const _handleTreeRefresh = useCallback(async () => { - await refetch(); - await refreshFolders(); - }, [refetch, refreshFolders]); - - const _tableRefetch = useCallback(async (params?: any) => { - const nextParams = { ...(params || {}) }; - const nextFilters = { ...(nextParams.filters || {}) }; - nextFilters.folderId = selectedFolderId; - nextParams.filters = nextFilters; - await refetch(nextParams); - }, [refetch, selectedFolderId]); - - useEffect(() => { - _tableRefetch({ page: 1, pageSize: 25 }); - }, [selectedFolderId, _tableRefetch]); - const formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); @@ -307,7 +299,7 @@ export const FilesPage: React.FC = () => {
⚠️

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

-
@@ -331,13 +323,12 @@ export const FilesPage: React.FC = () => {

{t('Dateiverwaltung')}

-
- {/* Split-view container */}
} style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} @@ -351,7 +342,7 @@ export const FilesPage: React.FC = () => { padding: '8px 4px', }}> { onSelectionChange={setTreeSelectedIds} expandedIds={expandedFolderIds} onToggleExpand={toggleFolderExpanded} - onRefresh={_handleTreeRefresh} + onRefresh={_refreshAll} onCreateFolder={handleCreateFolder} onRenameFolder={handleRenameFolder} onDeleteFolder={async (folderId) => { await handleDeleteFolder(folderId); if (selectedFolderId === folderId) setSelectedFolderId(null); - await refetch(); + await _tableRefetch(); }} onMoveFolder={handleMoveFolder} onMoveFolders={handleMoveFolders} - onMoveFile={_handleMoveFilePage} + onMoveFile={_handleMoveFile} onMoveFiles={_handleMoveFiles} onRenameFile={_handleRenameFile} onDeleteFile={_handleDeleteTreeFile} @@ -398,7 +389,6 @@ export const FilesPage: React.FC = () => { {/* Right panel: File table */}
- {/* Toolbar above table */}
{ )}
- {/* Table content */}
setSelectedFiles(rows as UserFile[])} - rowDraggable={true} - onRowDragStart={_onRowDragStart} - getRowDataAttributes={(row: UserFile) => - ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) - } - actionButtons={[ - ...(canUpdate ? [{ - type: 'edit' as const, - onAction: handleEditClick, - title: t('Bearbeiten'), - }] : []), - ...(canDelete ? [{ - type: 'delete' as const, - title: t('LΓΆschen'), - loading: (row: UserFile) => deletingFiles.has(row.id), - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - 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} - hookData={{ - refetch: _tableRefetch, - pagination, - permissions, - handleDelete: handleFileDelete, - handleInlineUpdate, - updateOptimistically: updateFileOptimistically, - }} - emptyMessage={t('Keine Dateien gefunden')} - /> + data={tableFiles || []} + columns={columns} + apiEndpoint="/api/files/list" + loading={tableLoading} + pagination={true} + pageSize={25} + searchable={true} + filterable={true} + sortable={true} + selectable={true} + onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])} + rowDraggable={true} + onRowDragStart={_onRowDragStart} + getRowDataAttributes={(row: UserFile) => + ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) + } + actionButtons={[ + ...(canUpdate ? [{ + type: 'edit' as const, + onAction: handleEditClick, + title: t('Bearbeiten'), + }] : []), + ...(canDelete ? [{ + type: 'delete' as const, + title: t('LΓΆschen'), + loading: (row: UserFile) => deletingFiles.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + 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} + hookData={{ + refetch: _tableRefetch, + pagination, + permissions, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + }} + emptyMessage={t('Keine Dateien gefunden')} + />
- {/* Edit Modal */} {editingFile && (
setEditingFile(null)}>
e.stopPropagation()}> diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx index 9cf2f17..68827bf 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx @@ -200,6 +200,8 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { label: t('Erstellt von'), type: 'string', width: 140, + fkSource: '/api/users/', + fkDisplayField: 'username', }, { key: 'sysCreatedAt', diff --git a/src/pages/views/neutralization/NeutralizationView.tsx b/src/pages/views/neutralization/NeutralizationView.tsx index b6b10e3..b7486e7 100644 --- a/src/pages/views/neutralization/NeutralizationView.tsx +++ b/src/pages/views/neutralization/NeutralizationView.tsx @@ -178,7 +178,7 @@ const ConfigTab: React.FC = () => { const PlaygroundTab: React.FC = () => { const { t } = useLanguage(); const { showSuccess, showError } = useToast(); - const { refetch: refetchFiles, handleFileDownload } = useFileContext(); + const { refreshTreeFiles: refetchFiles, handleFileDownload } = useFileContext(); const { connections } = useConnections(); const msftConnections = connections.filter(