frontend_nyla/src/pages/basedata/FilesPage.tsx
2026-04-17 21:48:41 +02:00

564 lines
22 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.

This file contains Unicode characters that might be confused with other characters. 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.
* 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<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(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<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'center' }}>
<div style={{ fontWeight: 600 }}>
{selectedFolderName
? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName })
: t('Dieser Ordner ist leer.')}
</div>
<div style={{ color: 'var(--text-muted, #64748b)' }}>
{t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')}
</div>
</div>
);
}, [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<UserFile>) => {
if (!editingFile) return;
const changes: Record<string, any> = {};
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<HTMLInputElement>) => {
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<HTMLTableRowElement>, 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 (
<div className={styles.adminPage}>
<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={() => _tableRefetch()}>
<FaSync /> {t('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}>{t('Dateien')}</h1>
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => _refreshAll()} disabled={tableLoading}>
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<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={folderNodes}
files={treeFileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={_handleTreeFileSelect}
selectedItemIds={treeSelectedIds}
onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => {
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}
/>
</div>
{/* Resizable divider */}
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
cursor: 'col-resize',
background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : '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' }}>
<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 /> {t('Neuer Ordner')}
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
</button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<FormGeneratorTable
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={[
{
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: <FaDownload />,
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}
/>
</div>
</div>
</div>
{editingFile && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('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>{t('Formular laden')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingFile}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingFile(null)}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
<PromptDialog />
</div>
);
};
export default FilesPage;