Adjuntar Archivos
Exportar contenido
Gestiona archivos y documentos relacionados con tareas.
Características
Sección titulada «Características»- Tipos soportados: Todos los tipos de archivo
- Tamaño máximo: 10 MB por archivo
- Storage: Supabase Storage (bucket:
task-attachments) - Organización:
user_id/task_id/filename - Vista previa: Iconos según tipo de archivo
- Acciones: Subir, descargar, eliminar
Tipos de Archivo Reconocidos
Sección titulada «Tipos de Archivo Reconocidos»| Tipo | Icono | Extensiones |
|---|---|---|
| Imágenes | 🖼️ | jpg, png, gif, svg, etc. |
| 📄 | ||
| Word | 📝 | doc, docx |
| Excel | 📊 | xls, xlsx, csv |
| Comprimidos | 🗜️ | zip, rar, 7z |
| Otros | 📁 | Resto |
Proceso de Carga
Sección titulada «Proceso de Carga»- Click en botón “Subir archivo”
- Seleccionar archivo del sistema
- Validación automática de tamaño
- Carga a Supabase Storage
- Registro en base de datos
- Aparece en lista con información
Descargar Archivo
Sección titulada «Descargar Archivo»- Click en icono de descarga
- Descarga automática del archivo con nombre original
Eliminar Archivo
Sección titulada «Eliminar Archivo»- Click en icono de basura
- Confirmación
- Eliminación de Storage y base de datos
Detalles Técnicos
Componente
Sección titulada «Componente»- Archivo:
TaskAttachments.tsx - Ruta:
src/features/projects/sub-modules/tasks/components/TaskAttachments.tsx
Tipo TaskAttachment
Sección titulada «Tipo TaskAttachment»interface TaskAttachment { id: string; task_id: string; file_name: string; file_path: string; file_size?: number; mime_type?: string; uploaded_by: string; created_at: string;}Mutación Subir Archivo
Sección titulada «Mutación Subir Archivo»const uploadFileMutation = useMutation({ mutationFn: async (file: File) => { if (!user?.id) throw new Error('User not authenticated');
// Validar tamaño (10MB max) if (file.size > 10 * 1024 * 1024) { throw new Error('El archivo no puede superar los 10MB'); }
setIsUploading(true);
// Generar nombre único const fileExt = file.name.split('.').pop(); const fileName = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`; const filePath = `${user.id}/${taskId}/${fileName}`;
// Subir a storage const { error: uploadError } = await supabase.storage .from('task-attachments') .upload(filePath, file);
if (uploadError) throw uploadError;
// Crear registro en DB const { error: dbError } = await supabase .from('task_attachments') .insert([{ task_id: taskId, file_name: file.name, file_path: filePath, file_size: file.size, mime_type: file.type, uploaded_by: user.id }]);
if (dbError) throw dbError; }, onSuccess: () => { setIsUploading(false); queryClient.invalidateQueries({ queryKey: ['task-detail', taskId] }); toast({ title: "Archivo adjuntado correctamente" }); }, onError: (error: Error) => { setIsUploading(false); toast({ title: "Error", description: error.message, variant: "destructive", }); }});Mutación Eliminar Archivo
Sección titulada «Mutación Eliminar Archivo»const deleteAttachmentMutation = useMutation({ mutationFn: async (attachment: TaskAttachment) => { // Eliminar de storage const { error: storageError } = await supabase.storage .from('task-attachments') .remove([attachment.file_path]);
if (storageError) throw storageError;
// Eliminar de database const { error: dbError } = await supabase .from('task_attachments') .delete() .eq('id', attachment.id);
if (dbError) throw dbError; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['task-detail', taskId] }); toast({ title: "Archivo eliminado" }); }});Función Descargar
Sección titulada «Función Descargar»const handleDownload = async (attachment: TaskAttachment) => { try { // Descargar de storage const { data, error } = await supabase.storage .from('task-attachments') .download(attachment.file_path);
if (error) throw error;
// Crear link de descarga temporal const url = URL.createObjectURL(data); const a = document.createElement('a'); a.href = url; a.download = attachment.file_name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { toast({ title: "Error", description: "No se pudo descargar el archivo", variant: "destructive", }); }};Función Icono por Tipo
Sección titulada «Función Icono por Tipo»const getFileIcon = (mimeType?: string) => { if (!mimeType) return <File className="h-4 w-4" />; if (mimeType.startsWith('image/')) return '🖼️'; if (mimeType.includes('pdf')) return '📄'; if (mimeType.includes('word')) return '📝'; if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊'; if (mimeType.includes('zip') || mimeType.includes('rar')) return '🗜️'; return <File className="h-4 w-4" />;};Input File Hidden
Sección titulada «Input File Hidden»<Input type="file" id={`file-upload-${taskId}`} className="hidden" onChange={handleFileSelect} disabled={isUploading}/><Button size="sm" variant="outline" asChild disabled={isUploading}> <label htmlFor={`file-upload-${taskId}`} className="cursor-pointer"> <Upload className="h-4 w-4 mr-2" /> {isUploading ? 'Subiendo...' : 'Subir archivo'} </label></Button>Tabla: task_attachments
Sección titulada «Tabla: task_attachments»CREATE TABLE task_attachments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, file_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_size BIGINT, mime_type VARCHAR(100), uploaded_by UUID NOT NULL REFERENCES auth.users(id), created_at TIMESTAMPTZ DEFAULT NOW());Storage Bucket Policy
Sección titulada «Storage Bucket Policy»-- Bucket: task-attachments-- Policy: Usuarios autenticados pueden CRUD sus propios archivos
CREATE POLICY "Users can upload own attachments"ON storage.objects FOR INSERTTO authenticatedWITH CHECK (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);
CREATE POLICY "Users can view own attachments"ON storage.objects FOR SELECTTO authenticatedUSING (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);
CREATE POLICY "Users can delete own attachments"ON storage.objects FOR DELETETO authenticatedUSING (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);Card UI
Sección titulada «Card UI»<Card> <CardHeader> <div className="flex items-center justify-between"> <CardTitle className="flex items-center gap-2"> <PaperclipIcon className="h-5 w-5" /> Archivos Adjuntos ({attachments.length}) </CardTitle> {/* Botón subir */} </div> </CardHeader> <CardContent> {attachments.length > 0 ? ( <div className="space-y-2"> {attachments.map(attachment => ( <div key={attachment.id} className="flex items-center p-3 border rounded-lg"> {/* Icono, nombre, tamaño, tipo */} {/* Botones descargar y eliminar */} </div> ))} </div> ) : ( <div className="text-center py-8 text-muted-foreground"> <PaperclipIcon className="h-12 w-12 mx-auto mb-3 opacity-20" /> <p>No hay archivos adjuntos</p> </div> )} </CardContent></Card>