Ir al contenido

Adjuntar Archivos

Gestiona archivos y documentos relacionados con tareas.

  • 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
TipoIconoExtensiones
Imágenes🖼️jpg, png, gif, svg, etc.
PDF📄pdf
Word📝doc, docx
Excel📊xls, xlsx, csv
Comprimidos🗜️zip, rar, 7z
Otros📁Resto
  1. Click en botón “Subir archivo”
  2. Seleccionar archivo del sistema
  3. Validación automática de tamaño
  4. Carga a Supabase Storage
  5. Registro en base de datos
  6. Aparece en lista con información
  1. Click en icono de descarga
  2. Descarga automática del archivo con nombre original
  1. Click en icono de basura
  2. Confirmación
  3. Eliminación de Storage y base de datos
Detalles Técnicos
  • Archivo: TaskAttachments.tsx
  • Ruta: src/features/projects/sub-modules/tasks/components/TaskAttachments.tsx
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;
}
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",
});
}
});
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" });
}
});
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",
});
}
};
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
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>
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()
);
-- Bucket: task-attachments
-- Policy: Usuarios autenticados pueden CRUD sus propios archivos
CREATE POLICY "Users can upload own attachments"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);
CREATE POLICY "Users can view own attachments"
ON storage.objects FOR SELECT
TO authenticated
USING (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);
CREATE POLICY "Users can delete own attachments"
ON storage.objects FOR DELETE
TO authenticated
USING (bucket_id = 'task-attachments' AND (storage.foldername(name))[1] = auth.uid()::text);
<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>