/** * FilesPage * * Split-view file management: FolderTree on the left, FormGeneratorTable on the right. * 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 api from '../../api'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { useFileContext } from '../../contexts/FileContext'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import FolderTree from '../../components/FolderTree/FolderTree'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { FaSync, FaUpload, FaDownload, 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'; import { getUserDataCache } from '../../utils/userCache'; interface UserFile { id: string; fileName: string; mimeType?: string; fileSize?: number; folderId?: string | null; featureInstanceId?: string; [key: string]: any; } export const FilesPage: React.FC = () => { const { t } = useLanguage(); const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); const { prompt: promptInput, PromptDialog } = usePrompt(); const [selectedFolderId, setSelectedFolderId] = useState(null); const { leftWidth, isDragging, handleMouseDown, containerRef, } = useResizablePanels({ storageKey: 'filesPage-panelWidth', defaultLeftWidth: 22, minLeftWidth: 15, maxLeftWidth: 40, }); // ── Table data (paginated, filtered by selectedFolderId) ────────────── const { data: tableFiles, attributes, permissions, loading: tableLoading, error, refetch: tableRefetch, pagination, fetchFileById, updateFileOptimistically, } = useUserFiles(); const { handleFileDownload, handleFileDelete, handleFileDeleteMultiple, handleFileUpload, handleFileUpdate, handleInlineUpdate, deletingFiles, downloadingFiles, uploadingFile, previewingFiles, } = useFileOperations(); // ── Tree data (from FileContext – lazy-loaded per expanded folder) ───── const { folders, refreshFolders, treeFileNodes, refreshTreeFiles, updateTreeFileNode, expandedFolderIds, toggleFolderExpanded, handleCreateFolder, handleRenameFolder, handleDeleteFolder, handleMoveFolder, handleMoveFolders, handleMoveFile: contextMoveFile, handleMoveFiles: contextMoveFiles, handleDownloadFolder, } = useFileContext(); const [editingFile, setEditingFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); const [treeSelectedIds, setTreeSelectedIds] = useState>(new Set()); const [highlightedFileId, setHighlightedFileId] = useState(null); // ── Table refetch: filter by real folderId ─────────────────────────── const _tableRefetch = useCallback(async (params?: any) => { const nextParams = { ...(params || {}) }; const nextFilters = { ...(nextParams.filters || {}) }; if (!selectedFolderId) { nextFilters.folderId = null; } else { nextFilters.folderId = selectedFolderId; } nextParams.filters = nextFilters; await tableRefetch(nextParams); }, [tableRefetch, selectedFolderId]); useEffect(() => { _tableRefetch({ page: 1, pageSize: 25 }); }, [selectedFolderId, _tableRefetch]); const _refreshAll = useCallback(async () => { await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); }, [_tableRefetch, refreshTreeFiles, refreshFolders]); const _handleScopeChange = useCallback(async (fileId: string, newScope: string) => { updateTreeFileNode(fileId, { scope: newScope }); try { await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); _tableRefetch(); } catch (err) { console.error('Failed to update scope:', err); await Promise.all([refreshTreeFiles(), _tableRefetch()]); } }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); const _handleNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { updateTreeFileNode(fileId, { neutralize: newValue }); try { await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); _tableRefetch(); } catch (err) { console.error('Failed to toggle neutralize:', err); await Promise.all([refreshTreeFiles(), _tableRefetch()]); } }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); // ── Folder nodes for tree (real folders only) ──────────────────────── const folderNodes = useMemo(() => { return folders.map(f => ({ id: f.id, name: f.name, parentId: f.parentId ?? null, fileCount: f.fileCount ?? 0, })); }, [folders]); const selectedFolderName = useMemo(() => { if (!selectedFolderId) return null; return folders.find(f => f.id === selectedFolderId)?.name ?? null; }, [folders, selectedFolderId]); const emptyTableMessage = useMemo(() => { if (!selectedFolderId) { return t('Keine Dateien gefunden'); } return (
{selectedFolderName ? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName }) : t('Dieser Ordner ist leer.')}
{t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')}
); }, [selectedFolderId, selectedFolderName, t]); // ── Columns ─────────────────────────────────────────────────────────── const columns = useMemo(() => { const hiddenColumns = ['id', 'fileHash', 'folderId']; const cols = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => ({ key: attr.name, label: attr.label || attr.name, type: attr.type as any, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, fkSource: (attr as any).fkSource, fkDisplayField: (attr as any).fkDisplayField, })); cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250, fkSource: '/api/users/', fkDisplayField: 'username', } as any); return cols; }, [attributes, t]); const canCreate = permissions?.create !== 'n'; const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; const currentUserId = useMemo(() => getUserDataCache()?.id || '', []); const _isOwned = useCallback((row: UserFile) => row.sysCreatedBy === currentUserId, [currentUserId]); // ── 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); }; const handleEditSubmit = async (data: Partial) => { if (!editingFile) return; const changes: Record = {}; const editableFields = ['fileName', 'scope', 'tags', 'description', 'folderId', 'neutralize'] as const; for (const field of editableFields) { if (data[field] !== undefined && data[field] !== editingFile[field]) { changes[field] = data[field]; } } if (Object.keys(changes).length === 0 && data.fileName) { changes.fileName = data.fileName; } const result = await handleFileUpdate(editingFile.id, changes); if (result.success) { setEditingFile(null); await Promise.all([_tableRefetch(), refreshTreeFiles()]); } }; const handleDelete = async (file: UserFile) => { const success = await handleFileDelete(file.id); 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) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); }; const handleDownload = async (file: UserFile) => { await handleFileDownload(file.id, file.fileName); }; const handleUploadClick = () => { fileInputRef.current?.click(); }; const handleFileSelect = async (e: React.ChangeEvent) => { const picked = e.target.files; if (picked && picked.length > 0) { let successCount = 0; let errorCount = 0; for (const file of Array.from(picked)) { const result = await handleFileUpload(file, undefined, undefined, selectedFolderId); if (result?.success) successCount++; else errorCount++; } if (fileInputRef.current) fileInputRef.current.value = ''; await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); if (successCount > 0) { showSuccess( t('Upload erfolgreich'), errorCount > 0 ? t('{successCount} Datei(en) hochgeladen, {errorCount} fehlgeschlagen', { successCount, errorCount }) : t('{successCount} Datei(en) hochgeladen', { successCount }), ); } else if (errorCount > 0) { showError(t('Upload fehlgeschlagen'), t('{errorCount} Datei(en) konnten nicht hochgeladen werden', { errorCount })); } } }; const _handleNewFolder = useCallback(async () => { const name = await promptInput(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') }); if (name?.trim()) { await handleCreateFolder(name.trim(), selectedFolderId); } }, [handleCreateFolder, selectedFolderId, promptInput, t]); const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); if (isInSelection && selectedFiles.length > 1) { 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 formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); if (error) { return (
⚠️

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

); } return (

{t('Dateien')}

{t('Dateiverwaltung')}

} style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} > {/* Left panel: FolderTree */}
{ await handleDeleteFolder(folderId); if (selectedFolderId === folderId) setSelectedFolderId(null); await _tableRefetch(); }} onMoveFolder={handleMoveFolder} onMoveFolders={handleMoveFolders} onMoveFile={_handleMoveFile} onMoveFiles={_handleMoveFiles} onRenameFile={_handleRenameFile} onDeleteFile={_handleDeleteTreeFile} onDeleteFiles={_handleDeleteTreeFiles} onDeleteFolders={_handleDeleteTreeFolders} onDownloadFolder={handleDownloadFolder} onScopeChange={_handleScopeChange} onNeutralizeToggle={_handleNeutralizeToggle} />
{/* Resizable divider */}
{ (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }} onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }} /> {/* Right panel: File table */}
{canCreate && ( )}
setSelectedFiles(rows as UserFile[])} rowDraggable={true} onRowDragStart={_onRowDragStart} getRowDataAttributes={(row: UserFile) => ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) } actionButtons={[ { type: 'view' as const, onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ }, title: t('Vorschau'), idField: 'id', nameField: 'fileName', typeField: 'mimeType', loadingStateName: 'previewingFiles', }, ...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: t('Bearbeiten'), disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false, }] : []), ...(canDelete ? [{ type: 'delete' as const, title: t('Löschen'), loading: (row: UserFile) => deletingFiles.has(row.id), disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false, }] : []), ]} customActions={[ { id: 'download', icon: , onClick: handleDownload, title: t('Herunterladen'), loading: (row: UserFile) => downloadingFiles.has(row.id), }, ]} onDelete={handleDelete} onDeleteMultiple={handleDeleteMultiple} hookData={{ refetch: _tableRefetch, pagination, permissions, handleDelete: handleFileDelete, handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, }} emptyMessage={emptyTableMessage} />
{editingFile && (

{t('Datei bearbeiten')}

{formAttributes.length === 0 ? (
{t('Formular laden')}
) : ( setEditingFile(null)} submitButtonText={t('Speichern')} cancelButtonText={t('Abbrechen')} /> )}
)}
); }; export default FilesPage;