ui-nyla/src/pages/basedata/FilesPage.tsx
Ida eb35e4b463
All checks were successful
Deploy Nyla Frontend INT / build-and-deploy (push) Successful in 10m27s
fix: node inhalt extrahieren nimmt jetzt context, files page formgenerator und folder tree zeigen jetzt die gleichen elemente
2026-05-26 12:03:53 +02:00

590 lines
22 KiB
TypeScript

/**
* 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<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const [viewMode, setViewMode] = useState<ViewMode>('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<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedOwnership, setSelectedOwnership] = useState<'own' | 'shared' | null>('own');
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
const [treeWidth, setTreeWidth] = useState(300);
const [treeVisible, setTreeVisible] = useState(true);
const [tableVisible, setTableVisible] = useState(true);
const draggingRef = useRef(false);
const splitContainerRef = useRef<HTMLDivElement>(null);
const _handleDividerPointerDown = useCallback((e: RPointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const _handleDividerPointerMove = useCallback((e: RPointerEvent<HTMLDivElement>) => {
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<string, unknown>;
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<string, unknown>,
parentColumnFilters?: Record<string, unknown>,
) => {
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<UserFile>) => {
if (!editingFile) return;
const changes: Record<string, any> = {};
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<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);
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<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 = '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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}>&#9888;&#65039;</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}>
<div style={{ display: 'flex', gap: 4, marginRight: 8 }}>
<button
className={treeVisible ? styles.primaryButton : styles.secondaryButton}
onClick={() => { if (!treeVisible || tableVisible) setTreeVisible(v => !v); }}
style={{ fontSize: 12, padding: '4px 8px' }}
title={treeVisible ? t('Ordnerstruktur ausblenden') : t('Ordnerstruktur einblenden')}
>
<FaTree />
</button>
<button
className={tableVisible ? styles.primaryButton : styles.secondaryButton}
onClick={() => { if (!tableVisible || treeVisible) setTableVisible(v => !v); }}
style={{ fontSize: 12, padding: '4px 8px' }}
title={tableVisible ? t('Tabelle ausblenden') : t('Tabelle einblenden')}
>
<FaTable />
</button>
</div>
<button className={styles.secondaryButton} onClick={_refreshAll} disabled={tableLoading}>
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div ref={splitContainerRef} style={{ flex: 1, overflow: 'hidden', display: 'flex', minHeight: 0 }}>
{/* Left panel: Tree */}
{treeVisible && (
<div style={{
width: tableVisible ? treeWidth : '100%',
flexShrink: 0,
overflow: 'auto',
display: 'flex', flexDirection: 'column',
}}>
<FormGeneratorTree
key={`own-${treeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
showFilter={true}
allowCreateFolder={canCreate}
onNodeClick={_handleTreeNodeClick}
onRefresh={() => _tableRefetch()}
/>
<FormGeneratorTree
key={`shared-${treeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleTreeNodeClick}
/>
</div>
)}
{/* Resizable divider */}
{treeVisible && tableVisible && (
<div
onPointerDown={_handleDividerPointerDown}
onPointerMove={_handleDividerPointerMove}
onPointerUp={_handleDividerPointerUp}
style={{
width: 6, cursor: 'col-resize', flexShrink: 0,
background: 'var(--color-border, #e0e0e0)',
position: 'relative', zIndex: 2,
touchAction: 'none',
}}
>
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 4, height: 32, borderRadius: 2,
background: 'var(--color-text-muted, #94a3b8)',
opacity: 0.4,
}} />
</div>
)}
{/* Right panel: Table with view-mode toggle */}
{tableVisible && (
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
borderBottom: '1px solid var(--color-border, #e0e0e0)',
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
}}>
<button
className={viewMode === 'folder' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setViewMode('folder')}
style={{ fontSize: 12, padding: '4px 10px' }}
>
{t('Ordner-Sicht')}
</button>
<button
className={viewMode === 'all' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setViewMode('all')}
style={{ fontSize: 12, padding: '4px 10px' }}
>
{t('Alle Dateien')}
</button>
<div style={{ flex: 1 }} />
{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"
tableContextKey="files/list"
tableGroupLayoutMode="inline"
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: () => {},
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: <FaDownload />,
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')}
/>
</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)}>&#10005;</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>
)}
</div>
);
};
export default FilesPage;