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, 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; } // 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 { 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 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); } } else { // Handle non-paginated response (backward compatibility) console.log('📋 Processing non-paginated response:', { isArray: Array.isArray(data), dataLength: Array.isArray(data) ? data.length : 'not an array', firstItemRaw: Array.isArray(data) && data.length > 0 ? data[0] : null, allDataRaw: data }); // Use backend data directly - no mapping needed, just like prompts const items = Array.isArray(data) ? data : []; console.log('📊 Final files array (non-paginated, using backend data directly):', items); setFiles(items); setPagination(null); } } catch (error: any) { // Error is already handled by useApiRequest setFiles([]); setPagination(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, fetchFileById, generateEditFieldsFromAttributes, ensureAttributesLoaded }; } // 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, folderId?: string | null, ) => { 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); } if (folderId) { formData.append('folderId', folderId); } // 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 management hook ────────────────────────────────────────────────── export function useFolders() { const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(false); const { showError } = useToast(); const refresh = useCallback(async () => { setLoading(true); try { const response = await api.get('/api/files/folders'); const data = Array.isArray(response.data) ? response.data : []; setFolders(data); } catch (err) { console.error('Failed to load folders:', err); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => { try { await api.post('/api/files/folders', { name, parentId: parentId || null }); await refresh(); } catch (err: any) { showError(err?.response?.data?.detail || err?.message || 'Folder creation failed'); throw err; } }, [refresh, showError]); const handleRenameFolder = useCallback(async (folderId: string, newName: string) => { try { await api.put(`/api/files/folders/${folderId}`, { name: newName }); await refresh(); } catch (err: any) { showError(err?.response?.data?.detail || err?.message || 'Rename failed'); throw err; } }, [refresh, showError]); const handleDeleteFolder = useCallback(async (folderId: string) => { try { await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } }); await refresh(); } catch (err: any) { showError(err?.response?.data?.detail || err?.message || 'Delete failed'); throw err; } }, [refresh, showError]); const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => { try { await api.post(`/api/files/folders/${folderId}/move`, { targetParentId }); await refresh(); } catch (err: any) { showError(err?.response?.data?.detail || err?.message || 'Move failed'); throw err; } }, [refresh, showError]); const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { try { await api.post(`/api/files/${fileId}/move`, { targetFolderId }); } catch (err: any) { showError(err?.response?.data?.detail || err?.message || 'Move failed'); throw err; } }, [showError]); return { folders, loading, refresh, handleCreateFolder, handleRenameFolder, handleDeleteFolder, handleMoveFolder, handleMoveFile, }; }