fixed udb foldertree

This commit is contained in:
ValueOn AG 2026-04-12 00:28:56 +02:00
parent 994664f0b4
commit dfb4c5ebd7
10 changed files with 438 additions and 378 deletions

View file

@ -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<string | null> => {
@ -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();

View file

@ -208,6 +208,7 @@ export interface FolderInfo {
id: string;
name: string;
parentId: string | null;
fileCount?: number;
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;

View file

@ -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<string>) => {
if (onSelectionChange) {

View file

@ -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;

View file

@ -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<HTMLDivElement>(null);
@ -256,16 +258,19 @@ function FilterValuesList({
return (
<>
{visibleValues.map(value => (
{visibleValues.map(value => {
const label = resolveLabel ? resolveLabel(value) : value;
return (
<div
key={value}
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
onClick={() => onSelect(value)}
title={value}
title={label}
>
{value.length > 30 ? value.substring(0, 30) + '...' : value}
{label.length > 30 ? label.substring(0, 30) + '...' : label}
</div>
))}
);
})}
{displayCount < allValues.length && (
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
)}
@ -884,6 +889,9 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// 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<T extends Record<string, any>>({
return column.filterOptions;
}
// FK columns: extract distinct values from actual data (Excel autofilter style)
if (column?.fkSource) {
const seen = new Set<string>();
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<T extends Record<string, any>>({
);
}
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<T extends Record<string, any>>({
allValues={getUniqueValuesForColumn(column.key)}
activeFilter={filters[column.key]}
onSelect={(value) => handleFilter(column.key, value)}
resolveLabel={column.fkSource ? (val) => fkCache[column.fkSource!]?.[val] || val : undefined}
/>
)}
</>

View file

@ -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<FilesTabProps> = ({ context, onFileSelect }) => {
const { t } = useLanguage();
const [files, setFiles] = useState<FileEntry[]>([]);
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<FilesTabProps> = ({ context, onFileSelect }) => {
const {
folders,
refreshFolders,
treeFileNodes,
treeFilesLoading,
refreshTreeFiles,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
@ -46,85 +37,32 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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<FilesTabProps> = ({ 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<FilesTabProps> = ({ 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 <div className={styles.loading}>{t('Dateien laden')}</div>;
if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>;
}
return (
<div
@ -301,7 +234,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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}

View file

@ -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<void>;
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
treeFileNodes: FileNode[];
treeFilesLoading: boolean;
loadTreeFiles: (folderId: string) => Promise<void>;
refreshTreeFiles: () => Promise<void>;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
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<void>;
uploadingFile: boolean;
deletingFiles: Set<string>;
previewingFiles: Set<string>;
downloadingFiles: Set<string>;
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
}
export const FileContext = createContext<FileContextType | undefined>(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<Set<string>>(() => {
try {
const stored = localStorage.getItem(_STORAGE_KEY);
const stored = sessionStorage.getItem(storageKey);
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
} catch { return new Set<string>(); }
});
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<string>(JSON.parse(stored)) : new Set<string>());
} catch { setExpandedFolderIds(new Set<string>()); }
}, [storageKey]);
// ── Folder state (single source of truth) ──────────────────────────────
// ── Folder state ──────────────────────────────────────────────────────
const [folders, setFolders] = useState<FolderInfo[]>([]);
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<Map<string, FileNode[]>>(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 (
<FileContext.Provider
value={{
files,
loading,
error,
refetch,
handleFileUpload,
folders,
foldersLoading,
refreshFolders,
treeFileNodes,
treeFilesLoading,
loadTreeFiles,
refreshTreeFiles,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles,
handleDownloadFolder,
handleFileDelete,
handleFileUpload,
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
handleFileDownload: async (fileId: string, fileName: string) => {
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}

View file

@ -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 {
@ -33,7 +32,6 @@ interface UserFile {
export const FilesPage: React.FC = () => {
const { t } = useLanguage();
const fileInputRef = useRef<HTMLInputElement>(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<UserFile | null>(null);
@ -94,43 +96,37 @@ export const FilesPage: React.FC = () => {
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(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<HTMLInputElement>) => {
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<HTMLTableRowElement>, 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 = () => {
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Dateien: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<button className={styles.secondaryButton} onClick={() => _tableRefetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -331,13 +323,12 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
<button className={styles.secondaryButton} onClick={() => _refreshAll()} disabled={tableLoading}>
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
{/* Split-view container */}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
@ -351,7 +342,7 @@ export const FilesPage: React.FC = () => {
padding: '8px 4px',
}}>
<FolderTree
folders={folders}
folders={folderNodes}
files={treeFileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
@ -361,17 +352,17 @@ export const FilesPage: React.FC = () => {
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 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar above table */}
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
@ -414,13 +404,12 @@ export const FilesPage: React.FC = () => {
)}
</div>
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}>
<FormGeneratorTable
data={filteredFiles}
data={tableFiles || []}
columns={columns}
apiEndpoint="/api/files/list"
loading={loading}
loading={tableLoading}
pagination={true}
pageSize={25}
searchable={true}
@ -477,7 +466,6 @@ export const FilesPage: React.FC = () => {
</div>
</div>
{/* Edit Modal */}
{editingFile && (
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>

View file

@ -200,6 +200,8 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
label: t('Erstellt von'),
type: 'string',
width: 140,
fkSource: '/api/users/',
fkDisplayField: 'username',
},
{
key: 'sysCreatedAt',

View file

@ -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(