bugs arreglados de FileDropZone

This commit is contained in:
2026-01-08 17:14:29 -06:00
parent cddc676f7d
commit b9c809e648
8 changed files with 402 additions and 193 deletions

View File

@@ -1,5 +1,5 @@
import { Upload, File, X, FileText } from 'lucide-react'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -28,33 +28,7 @@ export function FileDropzone({
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>([])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
}, [])
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
addFiles(selectedFiles)
}
},
[],
)
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const addFiles = useCallback(
(newFiles: Array<File>) => {
@@ -70,24 +44,59 @@ export function FileDropzone({
setFiles((prev) => {
const room = Math.max(0, maxFiles - prev.length)
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
if (onFilesChange) onFilesChange(next)
return next
})
},
[maxFiles, onFilesChange],
[maxFiles],
)
const removeFile = useCallback(
(fileId: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.id !== fileId)
if (onFilesChange) onFilesChange(next)
return next
})
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
},
[onFilesChange],
[addFiles],
)
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
addFiles(selectedFiles)
}
},
[addFiles],
)
const removeFile = useCallback((fileId: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.id !== fileId)
return next
})
}, [])
// Keep latest callback in a ref to avoid retriggering effect on identity change
useEffect(() => {
onFilesChangeRef.current = onFilesChange
}, [onFilesChange])
// Only emit when files actually change to avoid parent update loops
useEffect(() => {
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
}, [files])
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
@@ -116,7 +125,6 @@ export function FileDropzone({
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
isDragging && 'active',
)}
style={{ background: 'var(--gradient-subtle)' }}
>
<input
type="file"
@@ -126,7 +134,11 @@ export function FileDropzone({
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload" className="cursor-pointer">
<label
htmlFor="file-upload"
className="cursor-pointer"
aria-label="Seleccionar archivos"
>
<div className="flex flex-col items-center gap-3">
<div
className={cn(

View File

@@ -84,6 +84,38 @@ export function PasoDetallesPanel({
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
onToggleArchivo={(id, checked) =>
onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosReferencia: next,
},
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w) => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files) =>
onChange((w) => ({
...w,
@@ -142,6 +174,7 @@ export function PasoDetallesPanel({
<select
id="clonFacultad"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Facultad"
value={wizard.datosBasicos.facultadId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -166,6 +199,7 @@ export function PasoDetallesPanel({
<select
id="clonCarrera"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Carrera"
value={wizard.datosBasicos.carreraId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -264,12 +298,13 @@ export function PasoDetallesPanel({
<FileDropzone
acceptedTypes=".doc,.docx"
maxFiles={1}
onFilesChange={(file) => {
onFilesChange={(files) => {
const f = files[0] || null
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: file,
archivoWordPlanId: f,
},
}))
}}
@@ -281,17 +316,32 @@ export function PasoDetallesPanel({
id="mapa"
type="file"
accept=".xls,.xlsx"
title="Subir mapa curricular"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: next,
},
}
})
}
/>
</div>
@@ -301,17 +351,32 @@ export function PasoDetallesPanel({
id="asignaturas"
type="file"
accept=".xls,.xlsx,.csv"
title="Subir listado de asignaturas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: next,
},
}
})
}
/>
</div>
@@ -333,3 +398,9 @@ export function PasoDetallesPanel({
</Card>
)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

View File

@@ -17,8 +17,16 @@ import {
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({
selectedArchivoIds = [],
selectedRepositorioIds = [],
onToggleArchivo,
onToggleRepositorio,
onFilesChange,
}: {
selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string>
onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (
files: Array<{ id: string; name: string; size: string; type: string }>,
) => void
@@ -72,7 +80,13 @@ const ReferenciasParaIA = ({
key={archivo.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
<Checkbox
checked={selectedArchivoIds.includes(archivo.id)}
onCheckedChange={(checked) =>
onToggleArchivo?.(archivo.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<FileText className="text-muted-foreground h-4 w-4" />
@@ -113,7 +127,13 @@ const ReferenciasParaIA = ({
key={repositorio.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
<Checkbox
checked={selectedRepositorioIds.includes(repositorio.id)}
onCheckedChange={(checked) =>
onToggleRepositorio?.(repositorio.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<FolderOpen className="text-muted-foreground h-4 w-4" />
<div className="min-w-0 flex-1">