frontend_nyla/src/pages/basedata/FilesPage.tsx

529 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FilesPage
*
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right.
* Uses useResizablePanels for the divider.
*/
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 type { FileNode } from '../../components/FolderTree/FolderTree';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import styles from '../admin/Admin.module.css';
interface UserFile {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
featureInstanceId?: string;
[key: string]: any;
}
export const FilesPage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const {
leftWidth, isDragging, handleMouseDown, containerRef,
} = useResizablePanels({
storageKey: 'filesPage-panelWidth',
defaultLeftWidth: 22,
minLeftWidth: 15,
maxLeftWidth: 40,
});
const {
data: files,
attributes,
permissions,
loading,
error,
refetch,
pagination,
fetchFileById,
updateFileOptimistically,
} = useUserFiles();
const {
handleFileDownload,
handleFileDelete,
handleFileDeleteMultiple,
handleFileUpload,
handleFileUpdate,
handleFilePreview,
handleInlineUpdate,
deletingFiles,
downloadingFiles,
uploadingFile,
previewingFiles,
} = useFileOperations();
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
useEffect(() => { refetch(); }, []);
const treeFileNodes: FileNode[] = useMemo(() => {
if (!files) return [];
return files.map((f: UserFile) => ({
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]);
const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', '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: '_createdBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 100,
maxWidth: 250,
} as any);
return cols;
}, [attributes]);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const handleEditClick = async (file: UserFile) => {
const fullFile = await fetchFileById(file.id);
if (fullFile) setEditingFile(fullFile as UserFile);
};
const handleEditSubmit = async (data: Partial<UserFile>) => {
if (!editingFile) return;
const result = await handleFileUpdate(editingFile.id, {
fileName: data.fileName || editingFile.fileName
}, editingFile);
if (result.success) {
setEditingFile(null);
refetch();
}
};
const handleDelete = async (file: UserFile) => {
const success = await handleFileDelete(file.id);
if (success) refetch();
};
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
const ids = filesToDelete.map(f => f.id);
const success = await handleFileDeleteMultiple(ids);
if (success) refetch();
};
const handleDownload = async (file: UserFile) => {
await handleFileDownload(file.id, file.fileName);
};
const handlePreview = async (file: UserFile) => {
const result = await handleFilePreview(file.id, file.fileName, file.mimeType);
if (result.success && result.previewUrl) {
window.open(result.previewUrl, '_blank');
}
};
const handleUploadClick = () => { fileInputRef.current?.click(); };
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (selectedFiles && selectedFiles.length > 0) {
let successCount = 0;
let errorCount = 0;
for (const file of Array.from(selectedFiles)) {
const result = await handleFileUpload(file);
if (result?.success) successCount++; else errorCount++;
}
if (fileInputRef.current) fileInputRef.current.value = '';
await refetch();
if (successCount > 0) {
showSuccess(
'Upload erfolgreich',
`${successCount} Datei(en) hochgeladen${errorCount > 0 ? `, ${errorCount} fehlgeschlagen` : ''}`
);
} else if (errorCount > 0) {
showError('Upload fehlgeschlagen', `${errorCount} Datei(en) konnten nicht hochgeladen werden`);
}
}
};
const _handleNewFolder = useCallback(async () => {
const name = prompt('Neuer Ordnername:');
if (name?.trim()) {
await handleCreateFolder(name.trim(), selectedFolderId);
}
}, [handleCreateFolder, selectedFolderId]);
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));
} 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', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Dateien: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Dateien</h1>
<p className={styles.pageSubtitle}>Dateiverwaltung</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => { refetch(); refreshFolders(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> 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' }}
>
{/* Left panel: FolderTree */}
<div style={{
width: `${leftWidth}%`,
minWidth: 0,
overflow: 'auto',
borderRight: '1px solid var(--color-border, #e0e0e0)',
padding: '8px 4px',
}}>
<FolderTree
folders={folders}
files={treeFileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={_handleTreeFileSelect}
selectedItemIds={treeSelectedIds}
onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_handleTreeRefresh}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
await refetch();
}}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_handleMoveFilePage}
onMoveFiles={_handleMoveFiles}
onRenameFile={_handleRenameFile}
onDeleteFile={_handleDeleteTreeFile}
onDeleteFiles={_handleDeleteTreeFiles}
onDeleteFolders={_handleDeleteTreeFolders}
onDownloadFolder={handleDownloadFolder}
/>
</div>
{/* Resizable divider */}
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
cursor: 'col-resize',
background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
transition: isDragging ? 'none' : 'background 0.15s',
flexShrink: 0,
zIndex: 10,
}}
onMouseEnter={(e) => { (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 */}
<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)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
<FaFolderPlus /> Neuer Ordner
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? 'Uploading...' : 'Datei hochladen'}
</button>
)}
</div>
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : filteredFiles.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>
{selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'}
</h3>
<p className={styles.emptyDescription}>
{selectedFolderId
? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.'
: 'Laden Sie eine Datei hoch, um loszulegen.'}
</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> Datei hochladen
</button>
)}
</div>
) : (
<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: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: UserFile) => deletingFiles.has(row.id),
}] : []),
]}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: handleDownload,
title: 'Herunterladen',
loading: (row: UserFile) => downloadingFiles.has(row.id),
},
{
id: 'preview',
icon: <FaEye />,
onClick: handlePreview,
title: 'Vorschau',
loading: (row: UserFile) => previewingFiles.has(row.id),
},
]}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
hookData={{
refetch: _tableRefetch,
pagination,
permissions,
handleDelete: handleFileDelete,
handleInlineUpdate,
updateOptimistically: updateFileOptimistically,
}}
emptyMessage="Keine Dateien gefunden"
/>
)}
</div>
</div>
</div>
{/* Edit Modal */}
{editingFile && (
<div className={styles.modalOverlay} onClick={() => setEditingFile(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Datei bearbeiten</h2>
<button className={styles.modalClose} onClick={() => setEditingFile(null)}></button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingFile}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingFile(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default FilesPage;