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 // api.ts
import axios from 'axios'; import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; 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 // Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => { const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
@ -85,6 +85,13 @@ api.interceptors.request.use(
console.log('🍪 Using httpOnly cookies for authentication (automatic)'); 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) // Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks // This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl(); const context = getContextFromUrl();

View file

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

View file

@ -24,6 +24,7 @@ export interface FolderNode {
id: string; id: string;
name: string; name: string;
parentId: string | null; parentId: string | null;
fileCount?: number;
children?: FolderNode[]; children?: FolderNode[];
} }
@ -363,7 +364,7 @@ function _TreeNode({
const isNavSelected = selectedFolderId === node.id; const isNavSelected = selectedFolderId === node.id;
const isMultiSelected = sel.selectedItemIds.has(node.id); const isMultiSelected = sel.selectedItemIds.has(node.id);
const folderFiles = showFiles ? (filesByFolder.get(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(() => { useEffect(() => {
if (renaming && inputRef.current) inputRef.current.focus(); 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); if (next.has(id)) next.delete(id); else next.add(id);
return next; return next;
}); });
}, []); }, [onToggleExpand]);
const _setSelection = useCallback((ids: Set<string>) => { const _setSelection = useCallback((ids: Set<string>) => {
if (onSelectionChange) { if (onSelectionChange) {

View file

@ -141,9 +141,7 @@
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: left;
font-weight: 600; font-weight: 600;
font-size: 11px; font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #64748b); color: var(--color-text-secondary, #64748b);
white-space: nowrap; white-space: nowrap;
overflow: visible; overflow: visible;

View file

@ -223,11 +223,13 @@ function FilterValuesList({
allValues, allValues,
activeFilter, activeFilter,
onSelect, onSelect,
resolveLabel,
}: { }: {
columnKey: string; columnKey: string;
allValues: string[]; allValues: string[];
activeFilter: any; activeFilter: any;
onSelect: (value: string) => void; onSelect: (value: string) => void;
resolveLabel?: (value: string) => string;
}) { }) {
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE); const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
@ -256,16 +258,19 @@ function FilterValuesList({
return ( return (
<> <>
{visibleValues.map(value => ( {visibleValues.map(value => {
<div const label = resolveLabel ? resolveLabel(value) : value;
key={value} return (
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`} <div
onClick={() => onSelect(value)} key={value}
title={value} className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
> onClick={() => onSelect(value)}
{value.length > 30 ? value.substring(0, 30) + '...' : value} title={label}
</div> >
))} {label.length > 30 ? label.substring(0, 30) + '...' : label}
</div>
);
})}
{displayCount < allValues.length && ( {displayCount < allValues.length && (
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} /> <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 // Skip if column has static filterOptions (enum) those are used directly
if (column?.filterOptions && column.filterOptions.length > 0) return; 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 // Skip if already loaded or currently loading
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return; if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
@ -932,6 +940,22 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return column.filterOptions; 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) { if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
return asyncFilterValues[columnKey]; return asyncFilterValues[columnKey];
} }
@ -945,7 +969,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
); );
} }
return []; return [];
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]); }, [detectedColumns, asyncFilterValues, apiEndpoint, hookData, data, fkCache]);
// Close filter dropdown when clicking outside // Close filter dropdown when clicking outside
useEffect(() => { useEffect(() => {
@ -2012,6 +2036,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
allValues={getUniqueValuesForColumn(column.key)} allValues={getUniqueValuesForColumn(column.key)}
activeFilter={filters[column.key]} activeFilter={filters[column.key]}
onSelect={(value) => handleFilter(column.key, value)} 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 type { UdbContext } from './UnifiedDataBar';
import api from '../../api'; import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree'; import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree'; import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import styles from './FilesTab.module.css'; import styles from './FilesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; 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 { interface FilesTabProps {
context: UdbContext; context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void; onFileSelect?: (fileId: string, fileName?: string) => void;
@ -26,8 +14,6 @@ interface FilesTabProps {
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => { const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -37,6 +23,11 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const { const {
folders, folders,
refreshFolders, refreshFolders,
treeFileNodes,
treeFilesLoading,
refreshTreeFiles,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder, handleCreateFolder,
handleRenameFolder, handleRenameFolder,
handleDeleteFolder, handleDeleteFolder,
@ -46,85 +37,32 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
handleMoveFiles: contextMoveFiles, handleMoveFiles: contextMoveFiles,
handleFileDelete, handleFileDelete,
handleDownloadFolder, handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext(); } = 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(() => const _folderNodes = useMemo(() =>
folders.map(f => ({ folders.map(f => ({
id: f.id, id: f.id,
name: f.name, name: f.name,
parentId: f.parentId ?? null, parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0,
})), })),
[folders], [folders],
); );
const _fileNodes: FileNode[] = useMemo(() => { const _fileNodes: FileNode[] = useMemo(() => {
let result = files; let result = treeFileNodes;
if (searchQuery.trim()) { if (searchQuery.trim()) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
result = result.filter(f => result = result.filter(f =>
f.fileName.toLowerCase().includes(q) f.fileName.toLowerCase().includes(q),
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
); );
} }
return result return result;
.sort((a, b) => a.fileName.localeCompare(b.fileName)) }, [treeFileNodes, searchQuery]);
.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]);
const _refreshAll = useCallback(() => { const _refreshAll = useCallback(async () => {
_loadFiles(); await Promise.all([refreshTreeFiles(), refreshFolders()]);
refreshFolders(); }, [refreshTreeFiles, refreshFolders]);
}, [_loadFiles, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return; if (!context.instanceId || uploading) return;
@ -138,7 +76,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
} }
_refreshAll(); await _refreshAll();
} catch (err) { } catch (err) {
console.error('File upload failed:', err); console.error('File upload failed:', err);
} finally { } finally {
@ -178,62 +116,57 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId); await handleMoveFile(fileId, targetFolderId);
_loadFiles(); }, [handleMoveFile]);
}, [handleMoveFile, _loadFiles]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId); await contextMoveFiles(fileIds, targetFolderId);
_loadFiles(); }, [contextMoveFiles]);
}, [contextMoveFiles, _loadFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => { const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId); await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null); if (selectedFolderId === folderId) setSelectedFolderId(null);
_loadFiles(); }, [handleDeleteFolder, selectedFolderId]);
}, [handleDeleteFolder, selectedFolderId, _loadFiles]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => { const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName }); await api.put(`/api/files/${fileId}`, { fileName: newName });
_loadFiles(); await refreshTreeFiles();
}, [_loadFiles]); }, [refreshTreeFiles]);
const _onDeleteFile = useCallback(async (fileId: string) => { const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId); await handleFileDelete(fileId);
_loadFiles(); }, [handleFileDelete]);
}, [handleFileDelete, _loadFiles]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => { const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds }); await api.post('/api/files/batch-delete', { fileIds });
_loadFiles(); await Promise.all([refreshTreeFiles(), refreshFolders()]);
}, [_loadFiles]); }, [refreshTreeFiles, refreshFolders]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => { const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders(); await Promise.all([refreshFolders(), refreshTreeFiles()]);
_loadFiles(); }, [refreshFolders, refreshTreeFiles]);
}, [refreshFolders, _loadFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => { const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f)));
try { try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
await refreshTreeFiles();
} catch (err) { } catch (err) {
console.error('Failed to update scope:', err); console.error('Failed to update scope:', err);
_loadFiles();
} }
}, [_loadFiles]); }, [refreshTreeFiles]);
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f)));
try { try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
await refreshTreeFiles();
} catch (err) { } catch (err) {
console.error('Failed to toggle neutralize:', 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 ( return (
<div <div
@ -301,7 +234,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId} onSelect={setSelectedFolderId}
onFileSelect={onFileSelect ? (fileId: string) => { onFileSelect={onFileSelect ? (fileId: string) => {
const file = files.find(f => f.id === fileId); const file = treeFileNodes.find(f => f.id === fileId);
onFileSelect(fileId, file?.fileName); onFileSelect(fileId, file?.fileName);
} : undefined} } : undefined}
expandedIds={expandedFolderIds} 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 api from '../api';
import { useUserFiles, useFileOperations, UserFile } from '../hooks/useFiles'; import { useFileOperations } from '../hooks/useFiles';
import type { FolderInfo } from '../api/fileApi'; import type { FolderInfo } from '../api/fileApi';
import type { FileNode } from '../components/FolderTree/FolderTree';
export type { FolderInfo }; export type { FolderInfo };
interface FileContextType { interface FileContextType {
files: UserFile[]; folders: FolderInfo[];
loading: boolean; foldersLoading: boolean;
error: string | null; refreshFolders: () => Promise<void>;
refetch: () => Promise<void>;
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>; 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>; 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 }>; 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>; handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
uploadingFile: boolean; uploadingFile: boolean;
deletingFiles: Set<string>; deletingFiles: Set<string>;
previewingFiles: Set<string>; previewingFiles: Set<string>;
downloadingFiles: 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); 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 }) { export function FileProvider({ children }: { children: React.ReactNode }) {
const { data: files, loading, error, refetch: refetchFiles, removeFileOptimistically } = useUserFiles();
const { const {
handleFileUpload: hookHandleFileUpload, handleFileUpload: hookHandleFileUpload,
handleFileDelete: hookHandleFileDelete, handleFileDelete: hookHandleFileDelete,
@ -45,30 +63,33 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
uploadingFile, uploadingFile,
deletingFiles, deletingFiles,
previewingFiles, previewingFiles,
downloadingFiles downloadingFiles,
} = useFileOperations(); } = 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) ─────────────────── // ── Folder expanded state (persisted per feature-instance in sessionStorage) ──
const _STORAGE_KEY = 'folderTree-expandedIds';
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => { const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => {
try { try {
const stored = localStorage.getItem(_STORAGE_KEY); const stored = sessionStorage.getItem(storageKey);
return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>(); return stored ? new Set<string>(JSON.parse(stored)) : new Set<string>();
} catch { return new Set<string>(); } } catch { return new Set<string>(); }
}); });
const toggleFolderExpanded = useCallback((id: string) => { useEffect(() => {
setExpandedFolderIds(prev => { try {
const next = new Set(prev); const stored = sessionStorage.getItem(storageKey);
if (next.has(id)) next.delete(id); else next.add(id); setExpandedFolderIds(stored ? new Set<string>(JSON.parse(stored)) : new Set<string>());
try { localStorage.setItem(_STORAGE_KEY, JSON.stringify([...next])); } catch {} } catch { setExpandedFolderIds(new Set<string>()); }
return next; }, [storageKey]);
});
}, []);
// ── Folder state (single source of truth) ────────────────────────────── // ── Folder state ──────────────────────────────────────────────────────
const [folders, setFolders] = useState<FolderInfo[]>([]); const [folders, setFolders] = useState<FolderInfo[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false); const [foldersLoading, setFoldersLoading] = useState(false);
@ -87,6 +108,92 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { refreshFolders(); }, [refreshFolders]); 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) => { const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
await api.post('/api/files/folders', { name, parentId: parentId || null }); await api.post('/api/files/folders', { name, parentId: parentId || null });
await refreshFolders(); await refreshFolders();
@ -99,30 +206,53 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
const handleDeleteFolder = useCallback(async (folderId: string) => { const handleDeleteFolder = useCallback(async (folderId: string) => {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } }); await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
_removeTreeFiles(folderId);
await refreshFolders(); await refreshFolders();
await refetchFiles(); }, [refreshFolders, _removeTreeFiles]);
}, [refreshFolders, refetchFiles]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refreshFolders(); await refreshFolders();
}, [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) => { const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
await api.post('/api/files/batch-move', { folderIds, targetParentId }); await api.post('/api/files/batch-move', { folderIds, targetParentId });
await refreshFolders(); await refreshFolders();
}, [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) => { const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
try { try {
const response = await api.get(`/api/files/folders/${folderId}/download`, { 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 ( return (
<FileContext.Provider <FileContext.Provider
value={{ value={{
files, folders,
loading, foldersLoading,
error, refreshFolders,
refetch, treeFileNodes,
handleFileUpload, treeFilesLoading,
loadTreeFiles,
refreshTreeFiles,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles,
handleDownloadFolder,
handleFileDelete, handleFileDelete,
handleFileUpload,
handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'], handleFilePreview: handleFilePreview as FileContextType['handleFilePreview'],
handleFileDownload: async (fileId: string, fileName: string) => { handleFileDownload: async (fileId: string, fileName: string) => {
await handleFileDownload(fileId, fileName); await handleFileDownload(fileId, fileName);
@ -183,19 +301,6 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
deletingFiles, deletingFiles,
previewingFiles, previewingFiles,
downloadingFiles, downloadingFiles,
folders,
foldersLoading,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
handleMoveFiles,
handleMoveFolders,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
}} }}
> >
{children} {children}

View file

@ -2,7 +2,8 @@
* FilesPage * FilesPage
* *
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right. * 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'; 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 { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import FolderTree from '../../components/FolderTree/FolderTree'; import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useResizablePanels } from '../../hooks/useResizablePanels'; import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt'; import { usePrompt } from '../../hooks/usePrompt';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
interface UserFile { interface UserFile {
@ -32,8 +31,7 @@ interface UserFile {
} }
export const FilesPage: React.FC = () => { export const FilesPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt(); const { prompt: promptInput, PromptDialog } = usePrompt();
@ -48,13 +46,14 @@ export const FilesPage: React.FC = () => {
maxLeftWidth: 40, maxLeftWidth: 40,
}); });
// ── Table data (paginated, filtered by selectedFolderId) ──────────────
const { const {
data: files, data: tableFiles,
attributes, attributes,
permissions, permissions,
loading, loading: tableLoading,
error, error,
refetch, refetch: tableRefetch,
pagination, pagination,
fetchFileById, fetchFileById,
updateFileOptimistically, updateFileOptimistically,
@ -74,19 +73,22 @@ export const FilesPage: React.FC = () => {
previewingFiles, previewingFiles,
} = useFileOperations(); } = useFileOperations();
// ── Tree data (from FileContext lazy-loaded per expanded folder) ─────
const { const {
folders, folders,
refreshFolders, refreshFolders,
treeFileNodes,
refreshTreeFiles,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder, handleCreateFolder,
handleRenameFolder, handleRenameFolder,
handleDeleteFolder, handleDeleteFolder,
handleMoveFolder, handleMoveFolder,
handleMoveFolders, handleMoveFolders,
handleMoveFile, handleMoveFile: contextMoveFile,
handleMoveFiles: contextMoveFiles, handleMoveFiles: contextMoveFiles,
handleDownloadFolder, handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext(); } = useFileContext();
const [editingFile, setEditingFile] = useState<UserFile | null>(null); 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 [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!files) return []; _tableRefetch({ page: 1, pageSize: 25 });
return files.map((f: UserFile) => ({ }, [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, id: f.id,
fileName: f.fileName, name: f.name,
mimeType: f.mimeType, parentId: f.parentId ?? null,
fileSize: f.fileSize, fileCount: f.fileCount ?? 0,
folderId: f.folderId ?? null, })),
})); [folders],
}, [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]);
// ── Columns ───────────────────────────────────────────────────────────
const columns = useMemo(() => { const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', 'fileHash', 'folderId']; const hiddenColumns = ['id', 'fileHash', 'folderId'];
const cols = (attributes || []) const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name)) .filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({ .map(attr => ({
@ -146,7 +142,6 @@ export const FilesPage: React.FC = () => {
fkSource: (attr as any).fkSource, fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField, fkDisplayField: (attr as any).fkDisplayField,
})); }));
cols.push({ cols.push({
key: 'sysCreatedBy', key: 'sysCreatedBy',
label: t('Erstellt von'), label: t('Erstellt von'),
@ -157,8 +152,9 @@ export const FilesPage: React.FC = () => {
width: 150, width: 150,
minWidth: 100, minWidth: 100,
maxWidth: 250, maxWidth: 250,
fkSource: '/api/users/',
fkDisplayField: 'username',
} as any); } as any);
return cols; return cols;
}, [attributes, t]); }, [attributes, t]);
@ -166,6 +162,51 @@ export const FilesPage: React.FC = () => {
const canUpdate = permissions?.update !== 'n'; const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== '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 handleEditClick = async (file: UserFile) => {
const fullFile = await fetchFileById(file.id); const fullFile = await fetchFileById(file.id);
if (fullFile) setEditingFile(fullFile as UserFile); if (fullFile) setEditingFile(fullFile as UserFile);
@ -178,19 +219,19 @@ export const FilesPage: React.FC = () => {
}, editingFile); }, editingFile);
if (result.success) { if (result.success) {
setEditingFile(null); setEditingFile(null);
refetch(); await Promise.all([_tableRefetch(), refreshTreeFiles()]);
} }
}; };
const handleDelete = async (file: UserFile) => { const handleDelete = async (file: UserFile) => {
const success = await handleFileDelete(file.id); const success = await handleFileDelete(file.id);
if (success) refetch(); if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
}; };
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
const ids = filesToDelete.map(f => f.id); const ids = filesToDelete.map(f => f.id);
const success = await handleFileDeleteMultiple(ids); const success = await handleFileDeleteMultiple(ids);
if (success) refetch(); if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
}; };
const handleDownload = async (file: UserFile) => { const handleDownload = async (file: UserFile) => {
@ -207,16 +248,16 @@ export const FilesPage: React.FC = () => {
const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleUploadClick = () => { fileInputRef.current?.click(); };
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files; const picked = e.target.files;
if (selectedFiles && selectedFiles.length > 0) { if (picked && picked.length > 0) {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
for (const file of Array.from(selectedFiles)) { for (const file of Array.from(picked)) {
const result = await handleFileUpload(file); const result = await handleFileUpload(file);
if (result?.success) successCount++; else errorCount++; if (result?.success) successCount++; else errorCount++;
} }
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
await refetch(); await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]);
if (successCount > 0) { if (successCount > 0) {
showSuccess( showSuccess(
t('Upload erfolgreich'), t('Upload erfolgreich'),
@ -240,62 +281,13 @@ export const FilesPage: React.FC = () => {
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => { const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
const isInSelection = selectedFiles.some(f => f.id === row.id); const isInSelection = selectedFiles.some(f => f.id === row.id);
if (isInSelection && selectedFiles.length > 1) { if (isInSelection && selectedFiles.length > 1) {
const ids = selectedFiles.map(f => f.id); e.dataTransfer.setData('application/file-ids', JSON.stringify(selectedFiles.map(f => f.id)));
e.dataTransfer.setData('application/file-ids', JSON.stringify(ids));
} else { } else {
e.dataTransfer.setData('application/file-id', row.id); e.dataTransfer.setData('application/file-id', row.id);
} }
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
}, [selectedFiles]); }, [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 formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
@ -307,7 +299,7 @@ export const FilesPage: React.FC = () => {
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<span className={styles.errorIcon}></span> <span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Dateien: {detail}', { detail: String(error) })}</p> <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')} <FaSync /> {t('Erneut versuchen')}
</button> </button>
</div> </div>
@ -331,13 +323,12 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p> <p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}> <button className={styles.secondaryButton} onClick={() => _refreshAll()} disabled={tableLoading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
</div> </div>
</div> </div>
{/* Split-view container */}
<div <div
ref={containerRef as React.RefObject<HTMLDivElement>} ref={containerRef as React.RefObject<HTMLDivElement>}
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
@ -351,7 +342,7 @@ export const FilesPage: React.FC = () => {
padding: '8px 4px', padding: '8px 4px',
}}> }}>
<FolderTree <FolderTree
folders={folders} folders={folderNodes}
files={treeFileNodes} files={treeFileNodes}
showFiles={true} showFiles={true}
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
@ -361,17 +352,17 @@ export const FilesPage: React.FC = () => {
onSelectionChange={setTreeSelectedIds} onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds} expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded} onToggleExpand={toggleFolderExpanded}
onRefresh={_handleTreeRefresh} onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder} onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder} onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => { onDeleteFolder={async (folderId) => {
await handleDeleteFolder(folderId); await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null); if (selectedFolderId === folderId) setSelectedFolderId(null);
await refetch(); await _tableRefetch();
}} }}
onMoveFolder={handleMoveFolder} onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders} onMoveFolders={handleMoveFolders}
onMoveFile={_handleMoveFilePage} onMoveFile={_handleMoveFile}
onMoveFiles={_handleMoveFiles} onMoveFiles={_handleMoveFiles}
onRenameFile={_handleRenameFile} onRenameFile={_handleRenameFile}
onDeleteFile={_handleDeleteTreeFile} onDeleteFile={_handleDeleteTreeFile}
@ -398,7 +389,6 @@ export const FilesPage: React.FC = () => {
{/* Right panel: File table */} {/* Right panel: File table */}
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar above table */}
<div style={{ <div style={{
display: 'flex', gap: 8, padding: '8px 12px', display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)', borderBottom: '1px solid var(--color-border, #e0e0e0)',
@ -414,70 +404,68 @@ export const FilesPage: React.FC = () => {
)} )}
</div> </div>
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
<FormGeneratorTable <FormGeneratorTable
data={filteredFiles} data={tableFiles || []}
columns={columns} columns={columns}
apiEndpoint="/api/files/list" apiEndpoint="/api/files/list"
loading={loading} loading={tableLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}
searchable={true} searchable={true}
filterable={true} filterable={true}
sortable={true} sortable={true}
selectable={true} selectable={true}
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])} onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
rowDraggable={true} rowDraggable={true}
onRowDragStart={_onRowDragStart} onRowDragStart={_onRowDragStart}
getRowDataAttributes={(row: UserFile) => getRowDataAttributes={(row: UserFile) =>
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
} }
actionButtons={[ actionButtons={[
...(canUpdate ? [{ ...(canUpdate ? [{
type: 'edit' as const, type: 'edit' as const,
onAction: handleEditClick, onAction: handleEditClick,
title: t('Bearbeiten'), title: t('Bearbeiten'),
}] : []), }] : []),
...(canDelete ? [{ ...(canDelete ? [{
type: 'delete' as const, type: 'delete' as const,
title: t('Löschen'), title: t('Löschen'),
loading: (row: UserFile) => deletingFiles.has(row.id), loading: (row: UserFile) => deletingFiles.has(row.id),
}] : []), }] : []),
]} ]}
customActions={[ customActions={[
{ {
id: 'download', id: 'download',
icon: <FaDownload />, icon: <FaDownload />,
onClick: handleDownload, onClick: handleDownload,
title: t('Herunterladen'), title: t('Herunterladen'),
loading: (row: UserFile) => downloadingFiles.has(row.id), loading: (row: UserFile) => downloadingFiles.has(row.id),
}, },
{ {
id: 'preview', id: 'preview',
icon: <FaEye />, icon: <FaEye />,
onClick: handlePreview, onClick: handlePreview,
title: t('Vorschau'), title: t('Vorschau'),
loading: (row: UserFile) => previewingFiles.has(row.id), loading: (row: UserFile) => previewingFiles.has(row.id),
}, },
]} ]}
onDelete={handleDelete} onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple} onDeleteMultiple={handleDeleteMultiple}
hookData={{ hookData={{
refetch: _tableRefetch, refetch: _tableRefetch,
pagination, pagination,
permissions, permissions,
handleDelete: handleFileDelete, handleDelete: handleFileDelete,
handleInlineUpdate, handleInlineUpdate,
updateOptimistically: updateFileOptimistically, updateOptimistically: updateFileOptimistically,
}} }}
emptyMessage={t('Keine Dateien gefunden')} emptyMessage={t('Keine Dateien gefunden')}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Edit Modal */}
{editingFile && ( {editingFile && (
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}> <div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>

View file

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

View file

@ -178,7 +178,7 @@ const ConfigTab: React.FC = () => {
const PlaygroundTab: React.FC = () => { const PlaygroundTab: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { refetch: refetchFiles, handleFileDownload } = useFileContext(); const { refreshTreeFiles: refetchFiles, handleFileDownload } = useFileContext();
const { connections } = useConnections(); const { connections } = useConnections();
const msftConnections = connections.filter( const msftConnections = connections.filter(