import { useState, useEffect, useCallback } from 'react'; import api from '../api'; import { useToast } from '../contexts/ToastContext'; import { useLanguage } from '../providers/language/LanguageContext'; import { getUserDataCache } from '../utils/userCache'; import { useApiRequest } from './useApi'; import { usePermissions, type UserPermissions } from './usePermissions'; import { fetchFileAttributes as _fetchFileAttributes, fetchFiles as fetchFilesApi, fetchFileById as fetchFileByIdApi, updateFile as updateFileApi, deleteFile as deleteFileApi, deleteFiles as deleteFilesApi, getFolderTree, createFolder as createFolderApi, renameFolder as renameFolderApi, moveFolder as moveFolderApi, deleteFolderCascade as deleteFolderCascadeApi, patchFolderScope as patchFolderScopeApi, patchFolderNeutralize as patchFolderNeutralizeApi, moveFiles as moveFilesApi, type FolderInfo, } from '../api/fileApi'; export interface FilePreviewResult { success: boolean; previewUrl?: string; blob?: Blob; mimeType?: string; textContent?: string | null; error?: string; } // File interfaces - exactly matching backend FileItem model export interface FileInfo { id: string; mandateId: string; // Required in backend fileName: string; // Required in backend mimeType: string; fileHash: string; fileSize: number; creationDate: number; // Backend uses float for UTC timestamp in seconds } // UserFile is now just the backend response - no mapping needed // Field names come directly from backend attributes export type UserFile = any; // Attribute definition interface (local definition, not imported to avoid conflicts) export interface AttributeDefinition { name: string; label: string; type: 'string' | 'number' | 'date' | 'boolean' | 'enum'; sortable?: boolean; filterable?: boolean; searchable?: boolean; width?: number; minWidth?: number; maxWidth?: number; filterOptions?: string[]; // For enum types } // Pagination parameters (local definition, not imported to avoid conflicts) export interface PaginationParams { page?: number; pageSize?: number; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; viewKey?: string; } // Files list hook export function useUserFiles() { const [files, setFiles] = useState([]); const [attributes, setAttributes] = useState([]); const [permissions, setPermissions] = useState(null); const [pagination, setPagination] = useState<{ currentPage: number; pageSize: number; totalItems: number; totalPages: number; } | null>(null); const [groupLayout, setGroupLayout] = useState(null); const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null); const { request, isLoading: loading, error } = useApiRequest(); const { checkPermission } = usePermissions(); // Fetch attributes from backend const fetchAttributes = useCallback(async () => { try { // Note: fetchFileAttributes uses api.get directly due to response format handling // For now, we'll use api.get directly here as well const response = await api.get('/api/attributes/FileItem'); // Extract attributes from response - check if response.data.attributes exists, otherwise check if response.data is an array let attrs: AttributeDefinition[] = []; if (response.data?.attributes && Array.isArray(response.data.attributes)) { attrs = response.data.attributes; } else if (Array.isArray(response.data)) { attrs = response.data; } else if (response.data && typeof response.data === 'object') { // Try to find any array property in the response const keys = Object.keys(response.data); for (const key of keys) { if (Array.isArray(response.data[key])) { attrs = response.data[key]; break; } } } setAttributes(attrs); return attrs; } catch (error: any) { console.error('Error fetching attributes:', error); setAttributes([]); return []; } }, []); // Fetch permissions from backend const fetchPermissions = useCallback(async () => { try { const perms = await checkPermission('DATA', 'FileItem'); setPermissions(perms); return perms; } catch (error: any) { console.error('Error fetching permissions:', error); const defaultPerms: UserPermissions = { view: false, read: 'n', create: 'n', update: 'n', delete: 'n', }; setPermissions(defaultPerms); return defaultPerms; } }, [checkPermission]); const fetchGroupSectionSummaries = useCallback( async (base: { search?: string; filters?: Record; sort?: Array<{ field: string; direction: string }>; viewKey?: string | null; groupField: string; groupDirection?: 'asc' | 'desc'; }) => { const pObj: Record = { page: 1, pageSize: 25, groupByLevels: [ { field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc', }, ], }; if (base.search) (pObj as { search?: string }).search = base.search; if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters; if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort; if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey; const { data } = await api.get('/api/files/list', { params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) }, }); return Array.isArray(data?.groups) ? data.groups : []; }, [], ); const refetchForSection = useCallback( async ( paginationParams: PaginationParams & { page: number; pageSize: number }, sectionFilter: Record, parentColumnFilters?: Record, ) => { const mergedFilters = { ...(parentColumnFilters || {}), ...(paginationParams.filters || {}), ...sectionFilter, }; const pObj: Record = { page: paginationParams.page, pageSize: paginationParams.pageSize, filters: mergedFilters, groupByLevels: [], }; if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort; if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search; if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey; const { data } = await api.get('/api/files/list', { params: { pagination: JSON.stringify(pObj) }, }); if (data && typeof data === 'object' && 'items' in data) { return { items: data.items, pagination: data.pagination }; } return { items: [], pagination: null }; }, [], ); const fetchFiles = useCallback(async (params?: PaginationParams) => { // Check if user is authenticated before fetching files const cachedUser = getUserDataCache(); if (!cachedUser) { // User is not authenticated, skip fetching files setFiles([]); // Note: loading and error are managed by useApiRequest hook return; } try { const data = await fetchFilesApi(request, params); // Log the entire backend response for debugging console.log('📦 Backend response from /api/files/list:', { fullResponse: data, responseType: typeof data, isArray: Array.isArray(data), hasItems: data && typeof data === 'object' && 'items' in data, itemsCount: data && typeof data === 'object' && 'items' in data ? (Array.isArray(data.items) ? data.items.length : 'not an array') : 'no items property', firstItem: data && typeof data === 'object' && 'items' in data && Array.isArray(data.items) && data.items.length > 0 ? data.items[0] : 'no first item', pagination: data && typeof data === 'object' && 'pagination' in data ? data.pagination : 'no pagination' }); // Handle paginated response if (data && typeof data === 'object' && 'items' in data) { const items = Array.isArray(data.items) ? data.items : []; console.log('📋 Processing paginated response:', { itemsCount: items.length, firstItemRaw: items.length > 0 ? items[0] : null, allItemsRaw: items }); // Use backend data directly - no mapping needed, just like prompts // Field names come from backend attributes console.log('📊 Final files array (using backend data directly):', items); setFiles(items); if (data.pagination) { setPagination(data.pagination); } setGroupLayout((data as any).groupLayout ?? null); setAppliedView((data as any).appliedView ?? null); } else { const items = Array.isArray(data) ? data : []; setFiles(items); setPagination(null); setGroupLayout(null); setAppliedView(null); } } catch (error: any) { setFiles([]); setPagination(null); setGroupLayout(null); setAppliedView(null); } }, [request]); // Optimistically remove a file from the local state const removeFileOptimistically = (fileId: string) => { setFiles(prevFiles => prevFiles.filter(file => file.id !== fileId)); }; // Optimistically update a file in the local state const updateFileOptimistically = (fileId: string, updateData: any) => { setFiles(prevFiles => prevFiles.map(file => file.id === fileId ? { ...file, ...updateData } : file ) ); }; // Fetch a single file by ID const fetchFileById = useCallback(async (fileId: string): Promise => { return await fetchFileByIdApi(request, fileId); }, [request]); // Generate edit fields from attributes dynamically const generateEditFieldsFromAttributes = useCallback((): Array<{ key: string; label: string; type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly'; editable?: boolean; required?: boolean; validator?: (value: any) => string | null; minRows?: number; maxRows?: number; }> => { if (!attributes || attributes.length === 0) { return []; } const editableFields = attributes .filter(attr => { // Filter out non-editable fields (id, mandateId, fileHash, etc.) const nonEditableFields = ['id', 'mandateId', 'fileHash', 'creationDate', 'source']; return !nonEditableFields.includes(attr.name); }) .map(attr => { // Map attribute type to form field type let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly' = 'string'; if (attr.type === 'boolean') { fieldType = 'boolean'; } else if (attr.type === 'date') { fieldType = 'date'; } else if (attr.type === 'enum' && attr.filterOptions) { fieldType = 'enum'; } else if (attr.name === 'fileName' || attr.name === 'file_name') { fieldType = 'string'; } // Define validators and required fields let required = false; let validator: ((value: any) => string | null) | undefined = undefined; if (attr.name === 'fileName' || attr.name === 'file_name') { required = true; validator = (value: any) => { if (!value || (typeof value === 'string' && value.trim() === '')) { return 'File name cannot be empty'; } if (typeof value === 'string' && value.length > 255) { return 'File name cannot exceed 255 characters'; } return null; }; } return { key: attr.name, label: attr.label || attr.name, type: fieldType, editable: true, required, validator }; }); return editableFields; }, [attributes]); // Ensure attributes are loaded - can be called by EditActionButton const ensureAttributesLoaded = useCallback(async () => { // If attributes are already loaded, return them if (attributes && attributes.length > 0) { return attributes; } // Otherwise, fetch them and return the result const fetchedAttributes = await fetchAttributes(); return fetchedAttributes; }, [attributes, fetchAttributes]); // Fetch attributes and permissions on mount // Note: Do NOT fetch files here - let the table component control pagination useEffect(() => { fetchAttributes(); fetchPermissions(); }, [fetchAttributes, fetchPermissions]); // Listen for file upload events and refresh the list useEffect(() => { const handleFileUploaded = (event: CustomEvent) => { console.log('📁 File uploaded event received, refreshing list...', event.detail); // Small delay to ensure backend has persisted the file setTimeout(() => { fetchFiles(); }, 100); }; window.addEventListener('fileUploaded', handleFileUploaded as EventListener); return () => { window.removeEventListener('fileUploaded', handleFileUploaded as EventListener); }; }, [fetchFiles]); return { data: files, loading, error, refetch: fetchFiles, removeFileOptimistically, updateFileOptimistically, attributes, permissions, pagination, groupLayout, appliedView, fetchFileById, generateEditFieldsFromAttributes, ensureAttributesLoaded, fetchGroupSectionSummaries, refetchForSection, }; } // File operations hook export function useFileOperations() { const [downloadingFiles, setDownloadingFiles] = useState>(new Set()); const [deletingFiles, setDeletingFiles] = useState>(new Set()); const [editingFiles, setEditingFiles] = useState>(new Set()); const [uploadingFile, setUploadingFile] = useState(false); const [isLoading] = useState(false); const [downloadError, setDownloadError] = useState(null); const [deleteError, setDeleteError] = useState(null); const [uploadError, setUploadError] = useState(null); const [previewingFiles, setPreviewingFiles] = useState>(new Set()); const [previewError, setPreviewError] = useState(null); // Toast for notifications const { showWarning } = useToast(); // Language context const { t } = useLanguage(); // API request function for API layer calls const { request } = useApiRequest(); const handleFileDownload = async (fileId: string, fileName: string) => { setDownloadError(null); setDownloadingFiles(prev => new Set(prev).add(fileId)); try { // Try to get the file download const response = await api.get(`/api/files/${fileId}/download`, { responseType: 'blob', validateStatus: function (status: number) { return status >= 200 && status < 300; // default } }); const blob = response.data; // Create a download link and trigger the download const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.setAttribute('download', fileName); document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); return true; } catch (error: any) { console.error(`❌ Download failed for ${fileName}:`, error); let errorMessage = error.message; if (error.response?.status === 404) { errorMessage = `File "${fileName}" not found or has been deleted.`; } else if (error.response?.status === 403) { errorMessage = `No permission to download "${fileName}".`; } setDownloadError(errorMessage); return false; } finally { setDownloadingFiles(prev => { const newSet = new Set(prev); newSet.delete(fileId); return newSet; }); } }; const handleFileDelete = async (fileId: string, onOptimisticDelete?: () => void) => { setDeleteError(null); setDeletingFiles(prev => new Set(prev).add(fileId)); // Optimistically remove from UI if callback provided if (onOptimisticDelete) { onOptimisticDelete(); } try { await deleteFileApi(request, fileId); // Add a small delay to ensure backend has time to process await new Promise(resolve => setTimeout(resolve, 300)); return true; } catch (error: any) { console.error(`❌ Delete failed for file ID ${fileId}:`, error); let errorMessage = error.message; if (error.response?.status === 404) { errorMessage = `File not found or has already been deleted.`; // If file doesn't exist, consider it successfully "deleted" return true; } else if (error.response?.status === 403) { errorMessage = `No permission to delete this file.`; } setDeleteError(errorMessage); // If deletion failed and we optimistically removed it, we should refetch to restore the file return false; } finally { setDeletingFiles(prev => { const newSet = new Set(prev); newSet.delete(fileId); return newSet; }); } }; const handleFileDeleteMultiple = async (fileIds: string[], onOptimisticDelete?: (fileIds: string[]) => void) => { setDeleteError(null); setDeletingFiles(prev => { const newSet = new Set(prev); fileIds.forEach(id => newSet.add(id)); return newSet; }); // Optimistically remove from UI if callback provided if (onOptimisticDelete) { onOptimisticDelete(fileIds); } try { // Use API function for bulk delete const results = await deleteFilesApi(request, fileIds); // Check if any deletions failed const failures = results.filter(result => !result.success); if (failures.length > 0) { console.error(`❌ ${failures.length} out of ${fileIds.length} files failed to delete`); // For now, we'll consider it successful if at least some files were deleted // In a more robust implementation, you might want to handle partial failures differently } // Add a small delay to ensure backend has time to process await new Promise(resolve => setTimeout(resolve, 300)); return true; } catch (error: any) { console.error(`❌ Bulk delete failed:`, error); setDeleteError(error.message || 'Bulk delete failed'); return false; } finally { setDeletingFiles(prev => { const newSet = new Set(prev); fileIds.forEach(id => newSet.delete(id)); return newSet; }); } }; /** * File upload function - backend bug has been fixed! * * BACKEND FIXES APPLIED: * - Fixed file.fileName → file.filename in routeDataFiles.py * - Removed workflowId from FileItem creation in interfaceComponentObjects.py * - Upload should now work correctly */ const handleFileUpload = async ( file: globalThis.File, workflowId?: string, featureInstanceId?: string, ) => { setUploadError(null); setUploadingFile(true); try { // Validate file before upload if (!file || !file.name || file.name.trim() === '') { throw new Error('Invalid file: File must have a valid name'); } if (file.size === 0) { throw new Error('Invalid file: File cannot be empty'); } const formData = new FormData(); formData.append('file', file); if (workflowId) { formData.append('workflowId', workflowId); } if (featureInstanceId) { formData.append('featureInstanceId', featureInstanceId); } // FormData is now correctly configured for backend // Do NOT set Content-Type manually – axios sets multipart/form-data with boundary for FormData const response = await api.post('/api/files/upload', formData); const fileData = response.data; // Check if the response indicates a duplicate file if (fileData && fileData.isDuplicate && fileData.message) { const fileName = fileData.originalFileName || file.name; const messageTemplate = t('Die Datei "{fileName}" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.'); const message = messageTemplate.replace('{fileName}', fileName); showWarning(t('Datei bereits vorhanden'), message); } // Dispatch event to notify other components about the new file window.dispatchEvent(new CustomEvent('fileUploaded', { detail: fileData })); return { success: true, fileData }; } catch (error: any) { console.error('Upload failed:', error); let errorMessage = error.message; if (error.response?.status === 500) { if (errorMessage.includes('validation')) { errorMessage = 'File validation failed. Please ensure the file is valid and try again.'; } else { errorMessage = 'Server error during upload. Please try again later.'; } } setUploadError(errorMessage); return { success: false, error: errorMessage }; } finally { setUploadingFile(false); } }; const handleFileUpdate = async (fileId: string, updateData: Record, _originalFileData?: any) => { setUploadError(null); setEditingFiles(prev => new Set(prev).add(fileId)); try { const updatedFile = await updateFileApi(request, fileId, updateData); return { success: true, fileData: updatedFile }; } catch (error: any) { console.error(`Update failed for file ID ${fileId}:`, error); let errorMessage = error.message; if (error.response?.status === 404) { errorMessage = `File not found or has been deleted.`; } else if (error.response?.status === 403) { errorMessage = `No permission to update this file.`; } else if (error.response?.status === 400) { errorMessage = `Invalid file update data: ${error.response?.data?.detail || error.response?.data || errorMessage}`; } else if (error.response?.status === 422) { errorMessage = `Validation error: ${error.response?.data?.detail || errorMessage}`; } else if (error.response?.status === 500) { errorMessage = `Server error: ${error.response?.data?.detail || errorMessage}`; } setUploadError(errorMessage); return { success: false, error: errorMessage }; } finally { setEditingFiles(prev => { const newSet = new Set(prev); newSet.delete(fileId); return newSet; }); } }; const handleFilePreview = async ( fileId: string, fileName: string, _mimeType?: string, ): Promise => { setPreviewError(null); setPreviewingFiles(prev => new Set(prev).add(fileId)); try { const response = await api.get(`/api/files/${fileId}/preview`, { responseType: 'json', }); const data = response.data; if (!data || typeof data !== 'object' || typeof data.content !== 'string' || !data.mimeType) { throw new Error('Invalid preview response from server'); } const { content, mimeType: responseMime, isText } = data as { content: string; mimeType: string; isText?: boolean; encoding?: string | null; }; let blob: Blob; let textContent: string | null = null; if (isText) { textContent = content; blob = new Blob([content], { type: responseMime }); } else { const binaryString = atob(content); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } blob = new Blob([bytes], { type: responseMime }); } const previewUrl = window.URL.createObjectURL(blob); return { success: true, previewUrl, blob, mimeType: responseMime, textContent, }; } catch (error: any) { console.error(`Preview failed for ${fileName}:`, error); let errorMessage = error?.message ?? 'Unknown error'; if (error?.response?.status === 404) { errorMessage = `File "${fileName}" not found or has been deleted.`; } else if (error?.response?.status === 403) { errorMessage = `No permission to preview "${fileName}".`; } else if (error?.response?.status === 415) { errorMessage = `File type "${fileName}" is not supported for preview.`; } setPreviewError(errorMessage); return { success: false, error: errorMessage }; } finally { setPreviewingFiles(prev => { const newSet = new Set(prev); newSet.delete(fileId); return newSet; }); } }; const handleInlineUpdate = async (fileId: string, changes: Record, _existingRow?: any) => { const result = await handleFileUpdate(fileId, changes); if (!result.success) { throw new Error(result.error || 'Failed to update'); } return result; }; return { downloadingFiles, deletingFiles, editingFiles, uploadingFile, downloadError, deleteError, uploadError, previewingFiles, previewError, handleFileDownload, handleFileDelete, handleFileDeleteMultiple, handleFileUpload, handleFileUpdate, handleFilePreview, handleInlineUpdate, isLoading }; } // Folder operations hook export function useFolderOperations() { const [folderLoading, setFolderLoading] = useState(false); const [folderError, setFolderError] = useState(null); const { request } = useApiRequest(); const fetchOwnFolderTree = useCallback(async (): Promise => { setFolderLoading(true); setFolderError(null); try { return await getFolderTree(request, 'me'); } catch (err: any) { const msg = err?.message ?? 'Failed to fetch own folder tree'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const fetchSharedFolderTree = useCallback(async (): Promise => { setFolderLoading(true); setFolderError(null); try { return await getFolderTree(request, 'shared'); } catch (err: any) { const msg = err?.message ?? 'Failed to fetch shared folder tree'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handleCreateFolder = useCallback(async ( name: string, parentId?: string | null, ): Promise => { setFolderLoading(true); setFolderError(null); try { return await createFolderApi(request, name, parentId); } catch (err: any) { const msg = err?.message ?? 'Failed to create folder'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handleRenameFolder = useCallback(async ( folderId: string, name: string, ): Promise => { setFolderLoading(true); setFolderError(null); try { return await renameFolderApi(request, folderId, name); } catch (err: any) { const msg = err?.message ?? 'Failed to rename folder'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handleMoveFolder = useCallback(async ( folderId: string, parentId: string | null, ): Promise => { setFolderLoading(true); setFolderError(null); try { return await moveFolderApi(request, folderId, parentId); } catch (err: any) { const msg = err?.message ?? 'Failed to move folder'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handleDeleteFolderCascade = useCallback(async ( folderId: string, ): Promise<{ deletedFolders: number; deletedFiles: number }> => { setFolderLoading(true); setFolderError(null); try { return await deleteFolderCascadeApi(request, folderId); } catch (err: any) { const msg = err?.message ?? 'Failed to delete folder'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handlePatchFolderScope = useCallback(async ( folderId: string, scope: string, cascadeToFiles: boolean = false, ): Promise<{ folderId: string; scope: string; filesUpdated: number }> => { setFolderLoading(true); setFolderError(null); try { return await patchFolderScopeApi(request, folderId, scope, cascadeToFiles); } catch (err: any) { const msg = err?.message ?? 'Failed to patch folder scope'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handlePatchFolderNeutralize = useCallback(async ( folderId: string, neutralize: boolean, ): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> => { setFolderLoading(true); setFolderError(null); try { return await patchFolderNeutralizeApi(request, folderId, neutralize); } catch (err: any) { const msg = err?.message ?? 'Failed to patch folder neutralize'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); const handleMoveFiles = useCallback(async ( fileIds: string[], targetFolderId: string | null, ): Promise => { setFolderLoading(true); setFolderError(null); try { await moveFilesApi(request, fileIds, targetFolderId); } catch (err: any) { const msg = err?.message ?? 'Failed to move files'; setFolderError(msg); throw err; } finally { setFolderLoading(false); } }, [request]); return { folderLoading, folderError, fetchOwnFolderTree, fetchSharedFolderTree, handleCreateFolder, handleRenameFolder, handleMoveFolder, handleDeleteFolderCascade, handlePatchFolderScope, handlePatchFolderNeutralize, handleMoveFiles, }; }