Files
acad-ia-2/src/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel.tsx
Guillermo Arrieta Medina 9584cd0c04 Se cierran incidencias #10, #21, #24, #25; se añade generación manual de planes
close #10:
Al crear un plan de manera manual o con IA y redirigirse a planes/{$planId}/datos, sale el confetti.

close #21:
Los archivos que se adjuntan en el wizard ya no se pueden subir mas que una vez.

close #24:
El input de número de ciclos ahora solo permite enteros positivos mayores a 0.

close #25:
Se quitó el botón de generar borrador.
Al adjuntar el primer archivo al wizard, se hace scroll hasta el dropzone.
Los archivos añadidos se listan desde el más reciente al más antiguo.
Se indica claramente el número de archivos adjuntos y el número máximo de archivos que se pueden adjuntar.
2026-01-27 12:01:05 -06:00

374 lines
13 KiB
TypeScript

import { FileDropzone } from './FileDropZone'
import ReferenciasParaIA from './ReferenciasParaIA'
import type { UploadedFile } from './FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
CARRERAS,
FACULTADES,
PLANES_EXISTENTES,
} from '@/features/planes/nuevo/catalogs'
export function PasoDetallesPanel({
wizard,
onChange,
isLoading,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
isLoading: boolean
}) {
if (wizard.tipoOrigen === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Creación manual</CardTitle>
<CardDescription>
Se creará un plan en blanco con estructura mínima.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.tipoOrigen === 'IA') {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="desc">Descripción del enfoque académico</Label>
<textarea
id="desc"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Describe el enfoque del programa…"
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
descripcionEnfoqueAcademico: e.target.value,
},
}))
}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="notas">
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<textarea
id="notas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Lineamientos institucionales, restricciones, etc."
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
InstruccionesAdicionalesIA: e.target.value,
},
}))
}
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
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: Array<UploadedFile>) =>
onChange(
(w): NewPlanWizardState => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosAdjuntos: files,
},
}),
)
}
/>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<Label htmlFor="clonFacultad">Facultad</Label>
<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) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: e.target.value,
},
}))
}
>
<option value="">Todas</option>
{FACULTADES.map((f) => (
<option key={f.id} value={f.id}>
{f.nombre}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="clonCarrera">Carrera</Label>
<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) => ({
...w,
datosBasicos: {
...w.datosBasicos,
carreraId: e.target.value,
},
}))
}
>
<option value="">Todas</option>
{CARRERAS.map((c) => (
<option key={c.id} value={c.id}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="buscarPlan">Buscar</Label>
<Input
id="buscarPlan"
placeholder="Nombre del plan…"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value.toLowerCase()
void term
}}
/>
</div>
</div>
<div className="grid gap-3">
{PLANES_EXISTENTES.filter(
(p) =>
(!wizard.datosBasicos.facultadId ||
p.facultadId === wizard.datosBasicos.facultadId) &&
(!wizard.datosBasicos.carreraId ||
p.carreraId === wizard.datosBasicos.carreraId),
).map((p) => (
<Card
key={p.id}
className={
p.id === wizard.clonInterno?.planOrigenId
? 'ring-ring ring-2'
: ''
}
onClick={() =>
onChange((w) => ({ ...w, clonInterno: { planOrigenId: p.id } }))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{p.nombre}</span>
<span className="text-muted-foreground text-sm">
{p.estado} · {p.anio}
</span>
</CardTitle>
<CardDescription>ID: {p.id}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="word">Word del plan (obligatorio)</Label>
{/* <input
id="word"
type="file"
accept=".doc,.docx"
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)),
archivoWordPlanId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
}
/> */}
<FileDropzone
acceptedTypes=".doc,.docx"
maxFiles={1}
onFilesChange={(files) => {
const f = files[0] || null
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: f,
},
}))
}}
/>
</div>
<div>
<Label htmlFor="mapa">Excel del mapa curricular</Label>
<input
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) => {
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>
<div>
<Label htmlFor="asignaturas">Excel/listado de asignaturas</Label>
<input
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) => {
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>
<div className="text-muted-foreground text-sm">
Sube al menos Word y uno de los Excel para continuar.
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Selecciona un modo</CardTitle>
<CardDescription>
Elige una opción en el paso anterior para continuar.
</CardDescription>
</CardHeader>
</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'
}