/** * FilesPage * * Split-view file management: tree panel on the left (FormGeneratorTree), * FormGeneratorTable on the right. Two modes: * - "Ordner-Sicht": table filtered by selected folder in the tree * - "Alle Dateien": table shows all files without folder filter */ import React, { useState, useMemo, useEffect, useRef, useCallback, type PointerEvent as RPointerEvent } from 'react'; import { useUserFiles, useFileOperations, type PaginationParams } from '../../hooks/useFiles'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorTree } from '../../components/FormGenerator/FormGeneratorTree'; import { createFolderFileProvider } from '../../components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; import type { TreeNode } from '../../components/FormGenerator/FormGeneratorTree'; import { FaSync, FaUpload, FaDownload, FaTree, FaTable } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { getUserDataCache } from '../../utils/userCache'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; interface UserFile { id: string; fileName: string; mimeType?: string; fileSize?: number; featureInstanceId?: string; [key: string]: any; } type ViewMode = 'folder' | 'all'; type FileOwnerScope = 'all' | 'me' | 'shared'; function normalizeFolderFilterId(folderId: string | null): string | null { if (!folderId) return null; if (folderId.startsWith('__filesRoot:')) return null; return folderId; } function isSyntheticRootFolderId(folderId: string | null): boolean { return Boolean(folderId && folderId.startsWith('__filesRoot:')); } export const FilesPage: React.FC = () => { const { t } = useLanguage(); const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); const [viewMode, setViewMode] = useState('folder'); const provider = useMemo(() => createFolderFileProvider(), []); const [treeKey, setTreeKey] = useState(0); // ── Table data ──────────────────────────────────────────────────────── const { data: tableFiles, attributes, permissions, loading: tableLoading, error, refetch: tableRefetch, pagination, groupLayout, appliedView, fetchFileById, updateFileOptimistically, fetchGroupSectionSummaries: fetchGroupSectionSummariesFromHook, refetchForSection: refetchForSectionFromHook, } = useUserFiles(); const { handleFileDownload, handleFileDelete, handleFileDeleteMultiple, handleFileUpload, handleFileUpdate, handleInlineUpdate, deletingFiles, downloadingFiles, uploadingFile, previewingFiles, } = useFileOperations(); const [editingFile, setEditingFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolderId, setSelectedFolderId] = useState(null); const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own'); const [highlightedFileId, setHighlightedFileId] = useState(null); const [treeWidth, setTreeWidth] = useState(300); const [treeVisible, setTreeVisible] = useState(true); const [tableVisible, setTableVisible] = useState(true); const draggingRef = useRef(false); const splitContainerRef = useRef(null); const _handleDividerPointerDown = useCallback((e: RPointerEvent) => { e.preventDefault(); draggingRef.current = true; (e.target as HTMLElement).setPointerCapture(e.pointerId); }, []); const _handleDividerPointerMove = useCallback((e: RPointerEvent) => { if (!draggingRef.current || !splitContainerRef.current) return; const rect = splitContainerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; setTreeWidth(Math.max(180, Math.min(x, rect.width - 200))); }, []); const _handleDividerPointerUp = useCallback(() => { draggingRef.current = false; }, []); // ── Table refetch wrapper (filters by selectedFolderId in folder mode) ── const _tableRefetch = useCallback(async (params?: any) => { const nextParams = { ...(params || {}) }; const nextFilters = { ...(nextParams.filters || {}) }; const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); const rootSelected = isSyntheticRootFolderId(selectedFolderId); const owner: FileOwnerScope = selectedOwnership === 'own' ? 'me' : selectedOwnership === 'shared' ? 'shared' : 'all'; if (viewMode === 'folder' && selectedFolderId && !rootSelected) { nextFilters.folderId = normalizedFolderId; } else { delete nextFilters.folderId; } nextParams.filters = nextFilters; if (owner !== 'all') nextParams.owner = owner; else delete nextParams.owner; await tableRefetch(nextParams); }, [tableRefetch, selectedFolderId, selectedOwnership, viewMode]); const fetchGroupSectionSummaries = useCallback( async (base: { search?: string; filters?: Record; sort?: Array<{ field: string; direction: string }>; viewKey?: string | null; groupField: string; groupDirection?: 'asc' | 'desc'; }) => { const filters = { ...(base.filters || {}) }; const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); const rootSelected = isSyntheticRootFolderId(selectedFolderId); if (viewMode === 'folder' && selectedFolderId && !rootSelected) { filters.folderId = normalizedFolderId; } const owner: FileOwnerScope = selectedOwnership === 'own' ? 'me' : selectedOwnership === 'shared' ? 'shared' : 'all'; return fetchGroupSectionSummariesFromHook({ ...base, filters, owner }); }, [fetchGroupSectionSummariesFromHook, viewMode, selectedFolderId, selectedOwnership], ); const refetchForSection = useCallback( async ( paginationParams: PaginationParams & { page: number; pageSize: number }, sectionFilter: Record, parentColumnFilters?: Record, ) => { const merged = { ...(parentColumnFilters || {}) }; const normalizedFolderId = normalizeFolderFilterId(selectedFolderId); const rootSelected = isSyntheticRootFolderId(selectedFolderId); if (viewMode === 'folder' && selectedFolderId && !rootSelected) { merged.folderId = normalizedFolderId; } const owner: FileOwnerScope = selectedOwnership === 'own' ? 'me' : selectedOwnership === 'shared' ? 'shared' : 'all'; return refetchForSectionFromHook({ ...paginationParams, owner }, sectionFilter, merged); }, [refetchForSectionFromHook, viewMode, selectedFolderId, selectedOwnership], ); const _refreshAll = useCallback(async () => { await _tableRefetch({ page: 1, pageSize: 25 }); setTreeKey(k => k + 1); }, [_tableRefetch]); useEffect(() => { _tableRefetch({ page: 1, pageSize: 25 }); }, [selectedFolderId, selectedOwnership, viewMode, _tableRefetch]); // ── Tree interaction ────────────────────────────────────────────────── const _handleTreeNodeClick = useCallback((node: TreeNode) => { setSelectedOwnership(node.ownership); if (node.type === 'folder') { setSelectedFolderId(node.id); } else if (node.type === 'file') { setSelectedFolderId(node.parentId ?? null); setHighlightedFileId(node.id); requestAnimationFrame(() => { const row = document.querySelector('tr[data-highlighted="true"]'); if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); setTimeout(() => setHighlightedFileId(null), 2500); } }, []); // ── 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, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, displayField: (attr as any).displayField, frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, })); cols.push({ key: 'sysCreatedBy', label: t('Erstellt von'), sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250, displayField: 'sysCreatedByLabel', } as any); cols.push({ key: 'sysModifiedAt', label: t('Geaendert am'), type: 'timestamp', sortable: true, filterable: true, searchable: false, width: 170, minWidth: 130, maxWidth: 220, } as any); return resolveColumnTypes(cols, attributes || []); }, [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]); // ── 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', '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 _tableRefetch(); } }; const handleDelete = async (file: UserFile) => { const success = await handleFileDelete(file.id); if (success) await _tableRefetch(); }; const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const ids = filesToDelete.map(f => f.id); const success = await handleFileDeleteMultiple(ids); if (success) await _tableRefetch(); }; 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); if (result?.success) successCount++; else errorCount++; } if (fileInputRef.current) fileInputRef.current.value = ''; await _tableRefetch(); setTreeKey(k => k + 1); 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 _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 = 'copyMove'; }, [selectedFiles]); const formAttributes = useMemo(() => { const excludedFields = ['id', 'mandateId', 'fileHash', 'folderId', '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')}

{/* Left panel: Tree */} {treeVisible && (
_tableRefetch()} />
)} {/* Resizable divider */} {treeVisible && tableVisible && (
)} {/* Right panel: Table with view-mode toggle */} {tableVisible && (
{canCreate && ( )}
setSelectedFiles(rows as UserFile[])} rowDraggable={true} onRowDragStart={_onRowDragStart} getRowDataAttributes={(row: UserFile) => ({ highlighted: row.id === highlightedFileId ? 'true' : 'false', })} actionButtons={[ { type: 'view' as const, onAction: () => {}, 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 Eigentuemer kann bearbeiten') } : false, }] : []), ...(canDelete ? [{ type: 'delete' as const, title: t('Loeschen'), loading: (row: UserFile) => deletingFiles.has(row.id), disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentuemer kann loeschen') } : 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, groupLayout, appliedView, permissions, handleDelete: handleFileDelete, handleInlineUpdate, updateOptimistically: updateFileOptimistically, previewingFiles, fetchGroupSectionSummaries, refetchForSection, }} emptyMessage={t('Keine Dateien gefunden')} />
)}
{editingFile && (

{t('Datei bearbeiten')}

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