fixed udb foldertree
This commit is contained in:
parent
994664f0b4
commit
dfb4c5ebd7
10 changed files with 438 additions and 378 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ export interface FolderInfo {
|
|||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
fileCount?: number;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
createdAt?: number;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
<div
|
||||
key={value}
|
||||
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => onSelect(value)}
|
||||
title={value}
|
||||
>
|
||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||
</div>
|
||||
))}
|
||||
{visibleValues.map(value => {
|
||||
const label = resolveLabel ? resolveLabel(value) : value;
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => onSelect(value)}
|
||||
title={label}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* FilesPage
|
||||
*
|
||||
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
|
||||
* Uses useResizablePanels for the divider.
|
||||
* The tree is the master – it dictates which folder's files the table shows (paginated).
|
||||
* Tree files are managed by FileContext (lazy-loaded per expanded folder).
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
|
|
@ -12,13 +13,11 @@ import { useFileContext } from '../../contexts/FileContext';
|
|||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import FolderTree from '../../components/FolderTree/FolderTree';
|
||||
import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||
import { FaSync, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
interface UserFile {
|
||||
|
|
@ -32,8 +31,7 @@ interface UserFile {
|
|||
}
|
||||
|
||||
export const FilesPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { t } = useLanguage();
|
||||
const fileInputRef = useRef<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,70 +404,68 @@ export const FilesPage: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Table content */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<FormGeneratorTable
|
||||
data={filteredFiles}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/files/list"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||
rowDraggable={true}
|
||||
onRowDragStart={_onRowDragStart}
|
||||
getRowDataAttributes={(row: UserFile) =>
|
||||
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
||||
}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Bearbeiten'),
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: t('Löschen'),
|
||||
loading: (row: UserFile) => deletingFiles.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'download',
|
||||
icon: <FaDownload />,
|
||||
onClick: handleDownload,
|
||||
title: t('Herunterladen'),
|
||||
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
icon: <FaEye />,
|
||||
onClick: handlePreview,
|
||||
title: t('Vorschau'),
|
||||
loading: (row: UserFile) => previewingFiles.has(row.id),
|
||||
},
|
||||
]}
|
||||
onDelete={handleDelete}
|
||||
onDeleteMultiple={handleDeleteMultiple}
|
||||
hookData={{
|
||||
refetch: _tableRefetch,
|
||||
pagination,
|
||||
permissions,
|
||||
handleDelete: handleFileDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically: updateFileOptimistically,
|
||||
}}
|
||||
emptyMessage={t('Keine Dateien gefunden')}
|
||||
/>
|
||||
data={tableFiles || []}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/files/list"
|
||||
loading={tableLoading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||
rowDraggable={true}
|
||||
onRowDragStart={_onRowDragStart}
|
||||
getRowDataAttributes={(row: UserFile) =>
|
||||
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
||||
}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Bearbeiten'),
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: t('Löschen'),
|
||||
loading: (row: UserFile) => deletingFiles.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'download',
|
||||
icon: <FaDownload />,
|
||||
onClick: handleDownload,
|
||||
title: t('Herunterladen'),
|
||||
loading: (row: UserFile) => downloadingFiles.has(row.id),
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
icon: <FaEye />,
|
||||
onClick: handlePreview,
|
||||
title: t('Vorschau'),
|
||||
loading: (row: UserFile) => previewingFiles.has(row.id),
|
||||
},
|
||||
]}
|
||||
onDelete={handleDelete}
|
||||
onDeleteMultiple={handleDeleteMultiple}
|
||||
hookData={{
|
||||
refetch: _tableRefetch,
|
||||
pagination,
|
||||
permissions,
|
||||
handleDelete: handleFileDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically: updateFileOptimistically,
|
||||
}}
|
||||
emptyMessage={t('Keine Dateien gefunden')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingFile && (
|
||||
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
|
|
|
|||
|
|
@ -200,6 +200,8 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
|||
label: t('Erstellt von'),
|
||||
type: 'string',
|
||||
width: 140,
|
||||
fkSource: '/api/users/',
|
||||
fkDisplayField: 'username',
|
||||
},
|
||||
{
|
||||
key: 'sysCreatedAt',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue