Refactor: unifica wizards con WizardLayout/WizardResponsiveHeader y convierte asignaturas en layout con Outlet

- Se introdujo un layout genérico de wizard (WizardLayout) con headerSlot/footerSlot y se migraron los modales de Nuevo Plan y Nueva Asignatura a esta estructura usando defineStepper.
- Se creó y reutilizó WizardResponsiveHeader para un encabezado responsivo consistente (progreso en móvil y navegación en escritorio) en ambos wizards.
- Se homologó WizardControls del wizard de asignaturas para alinearlo al patrón del wizard de planes (props onPrev/onNext, flags de disable, manejo de error/loading y creación).
- Se mejoró la captura de datos en el wizard de asignatura: créditos como flotante con 2 decimales, placeholders/estilos en inputs/selects y uso de catálogo real de estructuras vía useSubjectEstructuras con qk.estructurasAsignatura.
- Se reorganizó la sección de asignaturas del detalle del plan: el contenido del antiguo index se movió a asignaturas.tsx como layout y se agregó <Outlet />; navegación a “nueva asignatura” ajustada al path correcto.
This commit is contained in:
2026-02-04 13:36:46 -06:00
parent fafe90e5e8
commit c82fac52f7
17 changed files with 824 additions and 433 deletions

View File

@@ -1,7 +1,7 @@
import type {
NewSubjectWizardState,
TipoAsignatura,
} from '@/features/asignaturas/nueva/types'
import { useEffect, useState } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -12,10 +12,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ESTRUCTURAS_SEP,
TIPOS_MATERIA,
} from '@/features/asignaturas/nueva/catalogs'
import { useSubjectEstructuras } from '@/data'
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
@@ -24,6 +23,20 @@ export function PasoBasicosForm({
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructuras } = useSubjectEstructuras()
const [creditosInput, setCreditosInput] = useState<string>(() => {
const c = Number(wizard.datosBasicos.creditos ?? 0)
return c > 0 ? c.toFixed(2) : ''
})
const [creditosFocused, setCreditosFocused] = useState(false)
useEffect(() => {
if (creditosFocused) return
const c = Number(wizard.datosBasicos.creditos ?? 0)
setCreditosInput(c > 0 ? c.toFixed(2) : '')
}, [wizard.datosBasicos.creditos, creditosFocused])
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
@@ -33,45 +46,66 @@ export function PasoBasicosForm({
placeholder="Ej. Matemáticas Discretas"
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="clave">Clave (Opcional)</Label>
<Label htmlFor="codigo">
Código
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="clave"
id="codigo"
placeholder="Ej. MAT-101"
value={wizard.datosBasicos.clave || ''}
value={wizard.datosBasicos.codigo || ''}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={wizard.datosBasicos.tipo}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
}))
value={(wizard.datosBasicos.tipo ?? '') as string}
onValueChange={(value: string) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
},
}),
)
}
>
<SelectTrigger
id="tipo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipo
? 'text-muted-foreground font-normal italic opacity-70'
: 'font-medium not-italic',
)}
>
<SelectValue />
<SelectValue placeholder="Ej. Obligatoria" />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
@@ -87,49 +121,175 @@ export function PasoBasicosForm({
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="number"
min={0}
value={wizard.datosBasicos.creditos}
onChange={(e) =>
type="text"
inputMode="decimal"
pattern="^\\d*(?:[.,]\\d{0,2})?$"
value={creditosInput}
onKeyDown={(e) => {
if (['-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onFocus={() => setCreditosFocused(true)}
onBlur={() => {
setCreditosFocused(false)
const raw = creditosInput.trim()
if (!raw) {
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const normalized = raw.replace(',', '.')
const asNumber = Number.parseFloat(normalized)
if (!Number.isFinite(asNumber) || asNumber <= 0) {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const fixed = asNumber.toFixed(2)
setCreditosInput(fixed)
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
}))
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const nextRaw = e.target.value
if (nextRaw === '') {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
setCreditosInput(nextRaw)
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: Number(e.target.value || 0),
creditos:
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
},
}))
}
}}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 4.50"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horas">Horas / Semana</Label>
<Label htmlFor="horasAcademicas">
Horas Académicas
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horas"
id="horasAcademicas"
type="number"
min={0}
value={wizard.datosBasicos.horasSemana || 0}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasSemana: Number(e.target.value || 0),
},
}))
min={1}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasAcademicas ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasAcademicas: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 48"
/>
</div>
<div className="grid gap-1 sm:col-span-2">
<div className="grid gap-1">
<Label htmlFor="horasIndependientes">
Horas Independientes
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasIndependientes"
type="number"
min={1}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasIndependientes ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasIndependientes: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 24"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId}
value={wizard.datosBasicos.estructuraId as string}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger
@@ -139,11 +299,15 @@ export function PasoBasicosForm({
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{ESTRUCTURAS_SEP.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.label}
</SelectItem>
))}
{estructuras?.map(
(
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">

View File

@@ -1,11 +1,11 @@
import * as Icons from 'lucide-react'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
@@ -21,22 +21,21 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
ARCHIVOS_SISTEMA_MOCK,
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/nueva/catalogs'
export function PasoConfiguracionPanel({
export function PasoDetallesPanel({
wizard,
onChange,
onGenerarIA,
onGenerarIA: _onGenerarIA,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void
}) {
if (wizard.modoCreacion === 'MANUAL') {
if (wizard.tipoOrigen === 'MANUAL') {
return (
<Card>
<CardHeader>
@@ -50,116 +49,104 @@ export function PasoConfiguracionPanel({
)
}
if (wizard.modoCreacion === 'IA') {
if (wizard.tipoOrigen === 'IA') {
return (
<div className="grid gap-4">
<div className="grid gap-1">
<Label>Descripción del enfoque</Label>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label>Descripción del enfoque académico</Label>
<Textarea
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
value={wizard.iaConfig?.descripcionEnfoque}
placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales."
value={wizard.iaConfig?.descripcionEnfoqueAcademico}
onChange={(e) =>
onChange((w) => ({
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoqueAcademico: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="flex flex-col gap-1">
<Label>
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Textarea
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos."
value={wizard.iaConfig?.instruccionesAdicionalesIA}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
instruccionesAdicionalesIA: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((a) => a !== id)
return {
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoque: e.target.value,
archivosReferencia: next,
},
}))
}
className="min-h-25"
/>
</div>
<div className="grid gap-1">
<Label>Notas adicionales</Label>
<Textarea
placeholder="Restricciones, bibliografía sugerida, etc."
value={wizard.iaConfig?.notasAdicionales}
onChange={(e) =>
onChange((w) => ({
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((r) => r !== id)
return {
...w,
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value },
}))
}
/>
</div>
<div className="grid gap-2">
<Label>Archivos de contexto (Opcional)</Label>
<div className="flex flex-col gap-2 rounded-md border p-3">
{ARCHIVOS_SISTEMA_MOCK.map((file) => (
<div key={file.id} className="flex items-center gap-2">
<input
type="checkbox"
id={file.id}
checked={wizard.iaConfig?.archivosExistentesIds.includes(
file.id,
)}
onChange={(e) => {
const checked = e.target.checked
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosExistentesIds: checked
? [
...(w.iaConfig?.archivosExistentesIds || []),
file.id,
]
: w.iaConfig?.archivosExistentesIds.filter(
(id) => id !== file.id,
) || [],
},
}))
}}
/>
<Label htmlFor={file.id} className="font-normal">
{file.name}
</Label>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
{wizard.isLoading ? (
<>
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
<>
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
</>
)}
</Button>
</div>
{wizard.resumen.previewAsignatura && (
<Card className="bg-muted/50 border-dashed">
<CardHeader>
<CardTitle className="text-base">Vista previa generada</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<p>
<strong>Objetivo:</strong>{' '}
{wizard.resumen.previewAsignatura.objetivo}
</p>
<p className="mt-2">
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
unidades temáticas y{' '}
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
bibliográficas.
</p>
</CardContent>
</Card>
)}
iaConfig: {
...w.iaConfig!,
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files: Array<UploadedFile>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosAdjuntos: files,
},
}),
)
}
/>
</div>
)
}
if (wizard.subModoClonado === 'INTERNO') {
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
@@ -217,12 +204,22 @@ export function PasoConfiguracionPanel({
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
role="button"
tabIndex={0}
onClick={() =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1'
@@ -245,7 +242,7 @@ export function PasoConfiguracionPanel({
)
}
if (wizard.subModoClonado === 'TRADICIONAL') {
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">

View File

@@ -1,10 +1,6 @@
import * as Icons from 'lucide-react'
import type {
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/nueva/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import {
Card,
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
wizard.tipoOrigen === modo
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
return (
<div className="grid gap-4 sm:grid-cols-3">
<Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'MANUAL',
subModoClonado: undefined,
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'MANUAL',
}),
)
}
role="button"
tabIndex={0}
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'IA',
subModoClonado: undefined,
}))
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA',
}),
)
}
role="button"
tabIndex={0}
@@ -69,8 +80,10 @@ export function PasoMetodoCardGroup({
</Card>
<Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
}
role="button"
tabIndex={0}
>
@@ -80,51 +93,79 @@ export function PasoMetodoCardGroup({
</CardTitle>
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
</CardHeader>
{wizard.modoCreacion === 'CLONADO' && (
<CardContent>
<div className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
}}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSubSelected('INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Database className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Del sistema</span>
<span className="text-xs opacity-70">
Buscar en otros planes
</span>
</div>
{(wizard.tipoOrigen === 'OTRO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<CardContent className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Database className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Del sistema</span>
<span className="text-xs opacity-70">
Buscar en otros planes
</span>
</div>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
}}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSubSelected('TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">
Subir Word existente
</span>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">Subir Word existente</span>
</div>
</div>
</CardContent>

View File

@@ -9,9 +9,38 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
import { usePlan, useSubjectEstructuras } from '@/data'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: estructuras } = useSubjectEstructuras()
const estructuraNombre = (() => {
const estructuraId = wizard.datosBasicos.estructuraId
if (!estructuraId) return '—'
const hit = estructuras?.find((e) => e.id === estructuraId)
return hit?.nombre ?? estructuraId
})()
const modoLabel = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
if (wizard.tipoOrigen === 'IA') return 'Generada con IA'
if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)'
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)'
return '—'
})()
const creditosText =
typeof wizard.datosBasicos.creditos === 'number' &&
Number.isFinite(wizard.datosBasicos.creditos)
? wizard.datosBasicos.creditos.toFixed(2)
: '—'
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
return (
<Card>
<CardHeader>
@@ -20,53 +49,145 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
Verifica los datos antes de crear la asignatura.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Nombre:</span>
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
<CardContent>
<div className="grid gap-4 text-sm">
<div className="grid gap-2">
<div>
<span className="text-muted-foreground">Plan de estudios: </span>
<span className="font-medium">
{plan?.nombre || wizard.plan_estudio_id || '—'}
</span>
</div>
{plan?.carreras?.nombre ? (
<div>
<span className="text-muted-foreground">Carrera: </span>
<span className="font-medium">{plan.carreras.nombre}</span>
</div>
) : null}
</div>
<div>
<span className="text-muted-foreground">Tipo:</span>
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Tipo de origen: </span>
<span className="inline-flex items-center gap-2 font-medium">
{wizard.tipoOrigen === 'MANUAL' && (
<Icons.Pencil className="h-4 w-4" />
)}
{wizard.tipoOrigen === 'IA' && (
<Icons.Sparkles className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<Icons.Copy className="h-4 w-4" />
)}
{modoLabel}
</span>
</div>
<div>
<span className="text-muted-foreground">Créditos:</span>
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
</div>
<div>
<span className="text-muted-foreground">Estructura:</span>
<div className="font-medium">
{
ESTRUCTURAS_SEP.find(
(e) => e.id === wizard.datosBasicos.estructuraId,
)?.label
}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombre || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Código: </span>
<span className="font-medium">
{wizard.datosBasicos.codigo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Tipo: </span>
<span className="font-medium">
{wizard.datosBasicos.tipo || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Créditos: </span>
<span className="font-medium">{creditosText}</span>
</div>
<div>
<span className="text-muted-foreground">Estructura: </span>
<span className="font-medium">{estructuraNombre}</span>
</div>
<div>
<span className="text-muted-foreground">Horas académicas: </span>
<span className="font-medium">
{wizard.datosBasicos.horasAcademicas ?? '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Horas independientes:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasIndependientes ?? '—'}
</span>
</div>
</div>
</div>
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Modo de creación:</span>
<div className="flex items-center gap-2 font-medium">
{wizard.modoCreacion === 'MANUAL' && (
<>
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
</>
)}
{wizard.modoCreacion === 'IA' && (
<>
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
</>
)}
{wizard.modoCreacion === 'CLONADO' && (
<>
<Icons.Copy className="h-4 w-4" /> Clonada
{wizard.subModoClonado === 'INTERNO'
? ' (Sistema)'
: ' (Archivo)'}
</>
)}
<div className="bg-muted/50 rounded-md p-3">
<div className="font-medium">Configuración IA</div>
<div className="mt-2 grid gap-2">
<div>
<span className="text-muted-foreground">
Enfoque académico:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Instrucciones adicionales:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
</span>
</div>
<div className="mt-2">
<div className="font-medium">Archivos de referencia</div>
{archivosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Repositorios de referencia</div>
{repositoriosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Archivos adjuntos</div>
{adjuntos.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
<li key={f.id}>
<span className="text-foreground">{f.file.name}</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
</div>
</div>
</div>
</CardContent>

View File

@@ -3,62 +3,71 @@ import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'
export function WizardControls({
Wizard,
methods,
wizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
setWizard,
errorMessage,
onPrev,
onNext,
disablePrev,
disableNext,
disableCreate,
isLastStep,
onCreate,
}: {
Wizard: any
methods: any
wizard: NewSubjectWizardState
canContinueDesdeMetodo: boolean
canContinueDesdeBasicos: boolean
canContinueDesdeConfig: boolean
onCreate: () => void
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
errorMessage?: string | null
onPrev: () => void
onNext: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
onCreate: () => Promise<void> | void
}) {
const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1
const handleCreate = async () => {
setWizard((w) => ({
...w,
isLoading: true,
errorMessage: null,
}))
try {
await onCreate()
} catch (err: any) {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
setWizard((w) => ({ ...w, isLoading: false }))
}
}
return (
<div className="flex items-center justify-between">
<div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="flex-1">
{wizard.errorMessage && (
{(errorMessage ?? wizard.errorMessage) && (
<span className="text-destructive text-sm font-medium">
{wizard.errorMessage}
{errorMessage ?? wizard.errorMessage}
</span>
)}
</div>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
{isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
{!isLast ? (
<Button
onClick={() => methods.next()}
disabled={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeConfig)
}
>
Siguiente
</Button>
) : (
<Button onClick={onCreate} disabled={wizard.isLoading}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
)}
</div>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
</Button>
)}
</div>
)
}

View File

@@ -246,9 +246,9 @@ export function PasoBasicosForm({
// Keep undefined when the input is empty so the field stays optional
numCiclos: (() => {
const raw = e.target.value
if (raw === '') return undefined
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return undefined
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
return n >= 1 ? n : 1

View File

@@ -90,7 +90,7 @@ export function PasoDetallesPanel({
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w) => {
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
@@ -105,7 +105,7 @@ export function PasoDetallesPanel({
})
}
onToggleRepositorio={(id, checked) =>
onChange((w) => {
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]

View File

@@ -11,6 +11,7 @@ import type {
TipoAsignatura,
UUID,
} from '../types/domain'
import type { Database } from '@/types/supabase'
const EDGE = {
subjects_create_manual: 'subjects_create_manual',
@@ -227,6 +228,23 @@ export async function subjects_get_document(
})
}
export async function subjects_get_structure_catalog(): Promise<
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('estructuras_asignatura')
.select('*')
.order('nombre', { ascending: true })
if (error) {
throw error
}
return data
}
export async function asignaturas_update(
asignaturaId: UUID,
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias

View File

@@ -99,7 +99,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
export function useCatalogosPlanes() {
return useQuery({
queryKey: ['catalogos_planes'],
queryKey: qk.estructurasPlan(),
queryFn: getCatalogos,
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
})

View File

@@ -11,6 +11,7 @@ import {
subjects_generate_document,
subjects_get,
subjects_get_document,
subjects_get_structure_catalog,
subjects_history,
subjects_import_from_file,
subjects_persist_from_ai,
@@ -68,6 +69,13 @@ export function useSubjectDocumento(subjectId: UUID | null | undefined) {
})
}
export function useSubjectEstructuras() {
return useQuery({
queryKey: qk.estructurasAsignatura(),
queryFn: () => subjects_get_structure_catalog(),
})
}
/* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() {

View File

@@ -4,7 +4,7 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
@@ -54,7 +54,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
canContinueDesdeDetalles,
simularGeneracionIA,
crearAsignatura,
} = useNuevaAsignaturaWizard(planId)
@@ -104,13 +104,24 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
Wizard={Wizard}
methods={methods}
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
setWizard={setWizard}
onCreate={async () => {
await crearAsignatura()
handleClose()
}}
/>
</Wizard.Stepper.Controls>
}
@@ -130,7 +141,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoConfiguracionPanel
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}

View File

@@ -1,19 +1,20 @@
import { useState } from "react";
import { useState } from 'react'
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
import type { AsignaturaPreview, NewSubjectWizardState } from '../types'
export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1,
planId,
modoCreacion: null,
plan_estudio_id: planId,
tipoOrigen: null,
datosBasicos: {
nombre: "",
clave: "",
tipo: "OBLIGATORIA",
creditos: 0,
horasSemana: 0,
estructuraId: "",
nombre: '',
codigo: '',
tipo: null,
creditos: null,
horasAcademicas: null,
horasIndependientes: null,
estructuraId: '',
},
clonInterno: {},
clonTradicional: {
@@ -21,42 +22,47 @@ export function useNuevaAsignaturaWizard(planId: string) {
archivosAdicionalesIds: [],
},
iaConfig: {
descripcionEnfoque: "",
notasAdicionales: "",
archivosExistentesIds: [],
descripcionEnfoqueAcademico: '',
instruccionesAdicionalesIA: '',
archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
});
})
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeMetodo =
wizard.tipoOrigen === 'MANUAL' ||
wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombre &&
wizard.datosBasicos.tipo !== null &&
wizard.datosBasicos.creditos !== null &&
wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId;
!!wizard.datosBasicos.estructuraId
const canContinueDesdeConfig = (() => {
if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque;
const canContinueDesdeDetalles = (() => {
if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA') {
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
}
if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") {
return !!wizard.clonInterno?.asignaturaOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return !!wizard.clonInterno?.asignaturaOrigenId
}
return false;
})();
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return !!wizard.clonTradicional?.archivoWordAsignaturaId
}
return false
})()
const simularGeneracionIA = async () => {
setWizard((w) => ({ ...w, isLoading: true }));
await new Promise((r) => setTimeout(r, 1500));
setWizard((w) => ({ ...w, isLoading: true }))
await new Promise((r) => setTimeout(r, 1500))
setWizard((w) => ({
...w,
isLoading: false,
@@ -64,27 +70,25 @@ export function useNuevaAsignaturaWizard(planId: string) {
previewAsignatura: {
nombre: w.datosBasicos.nombre,
objetivo:
"Aplicar los fundamentos teóricos para la resolución de problemas...",
'Aplicar los fundamentos teóricos para la resolución de problemas...',
unidades: 5,
bibliografiaCount: 3,
} as AsignaturaPreview,
},
}));
};
}))
}
const crearAsignatura = async (onCreated: () => void) => {
setWizard((w) => ({ ...w, isLoading: true }));
await new Promise((r) => setTimeout(r, 1000));
onCreated();
};
const crearAsignatura = async () => {
await new Promise((r) => setTimeout(r, 1000))
}
return {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
canContinueDesdeDetalles,
simularGeneracionIA,
crearAsignatura,
};
}
}

View File

@@ -1,45 +1,50 @@
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Asignatura } from '@/data'
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
export type SubModoClonado = 'INTERNO' | 'TRADICIONAL'
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
export type AsignaturaPreview = {
nombre: string;
objetivo: string;
unidades: number;
bibliografiaCount: number;
};
nombre: string
objetivo: string
unidades: number
bibliografiaCount: number
}
export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4;
planId: string;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
step: 1 | 2 | 3 | 4
plan_estudio_id: Asignatura['plan_estudio_id']
tipoOrigen: Asignatura['tipo_origen'] | null
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: string;
};
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: Asignatura['horas_academicas'] | null
horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null
}
clonInterno?: {
facultadId?: string;
carreraId?: string;
planOrigenId?: string;
asignaturaOrigenId?: string | null;
};
facultadId?: string
carreraId?: string
planOrigenId?: string
asignaturaOrigenId?: string | null
}
clonTradicional?: {
archivoWordAsignaturaId: string | null;
archivosAdicionalesIds: Array<string>;
};
archivoWordAsignaturaId: string | null
archivosAdicionalesIds: Array<string>
}
iaConfig?: {
descripcionEnfoque: string;
notasAdicionales: string;
archivosExistentesIds: Array<string>;
};
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
}
resumen: {
previewAsignatura?: AsignaturaPreview;
};
isLoading: boolean;
errorMessage: string | null;
};
previewAsignatura?: AsignaturaPreview
}
isLoading: boolean
errorMessage: string | null
}

View File

@@ -12,7 +12,7 @@ export function useNuevoPlanWizard() {
carrera: { id: '', nombre: '' },
nivel: '',
tipoCiclo: '',
numCiclos: undefined,
numCiclos: null,
estructuraPlanId: null,
},
// datosBasicos: {
@@ -56,7 +56,7 @@ export function useNuevoPlanWizard() {
!!wizard.datosBasicos.carrera.id &&
!!wizard.datosBasicos.facultad.id &&
!!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos !== undefined &&
wizard.datosBasicos.numCiclos !== null &&
wizard.datosBasicos.numCiclos > 0 &&
// Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.estructuraPlanId

View File

@@ -29,7 +29,7 @@ export type NewPlanWizardState = {
}
nivel: NivelPlanEstudio | ''
tipoCiclo: TipoCiclo | ''
numCiclos: number | undefined
numCiclos: number | null
// Selección de plantillas (obligatorias)
estructuraPlanId: string | null
}

View File

@@ -23,7 +23,7 @@ import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasIndexRouteImport } from './routes/planes/$planId/_detalle/asignaturas/index'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
const LoginRoute = LoginRouteImport.update({
@@ -102,17 +102,17 @@ const PlanesPlanIdDetalleDocumentoRoute =
path: '/documento',
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasIndexRoute =
PlanesPlanIdDetalleAsignaturasIndexRouteImport.update({
id: '/asignaturas/',
path: '/asignaturas/',
const PlanesPlanIdDetalleAsignaturasRoute =
PlanesPlanIdDetalleAsignaturasRouteImport.update({
id: '/asignaturas',
path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/asignaturas/nueva',
path: '/asignaturas/nueva',
getParentRoute: () => PlanesPlanIdDetalleRoute,
id: '/nueva',
path: '/nueva',
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -123,6 +123,7 @@ export interface FileRoutesByFullPath {
'/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -131,7 +132,6 @@ export interface FileRoutesByFullPath {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -140,6 +140,7 @@ export interface FileRoutesByTo {
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes': typeof PlanesListaRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -148,7 +149,6 @@ export interface FileRoutesByTo {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -159,6 +159,7 @@ export interface FileRoutesById {
'/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
@@ -167,7 +168,6 @@ export interface FileRoutesById {
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/_detalle/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -179,6 +179,7 @@ export interface FileRouteTypes {
| '/planes'
| '/planes/$planId'
| '/planes/nuevo'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
@@ -187,7 +188,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/'
| '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -196,6 +196,7 @@ export interface FileRouteTypes {
| '/demo/tanstack-query'
| '/planes'
| '/planes/nuevo'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
@@ -204,7 +205,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId'
| '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas'
id:
| '__root__'
| '/'
@@ -214,6 +214,7 @@ export interface FileRouteTypes {
| '/planes/_lista'
| '/planes/$planId/_detalle'
| '/planes/_lista/nuevo'
| '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial'
@@ -222,7 +223,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/_detalle/asignaturas/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -335,19 +335,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/asignaturas/': {
id: '/planes/$planId/_detalle/asignaturas/'
'/planes/$planId/_detalle/asignaturas': {
id: '/planes/$planId/_detalle/asignaturas'
path: '/asignaturas'
fullPath: '/planes/$planId/asignaturas/'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRouteImport
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
}
'/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/asignaturas/nueva'
path: '/nueva'
fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
}
}
}
@@ -364,28 +364,40 @@ const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
PlanesListaRouteChildren,
)
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
}
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
{
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
}
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
PlanesPlanIdDetalleAsignaturasRouteChildren,
)
interface PlanesPlanIdDetalleRouteChildren {
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
PlanesPlanIdDetalleAsignaturasIndexRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRoute
}
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
PlanesPlanIdDetalleAsignaturasRoute:
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
PlanesPlanIdDetalleAsignaturasIndexRoute:
PlanesPlanIdDetalleAsignaturasIndexRoute,
}
const PlanesPlanIdDetalleRouteWithChildren =

View File

@@ -1,4 +1,4 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import {
Plus,
Copy,
@@ -72,7 +72,7 @@ const mapAsignaturas = (asigApi: Array<any> = []): Array<Asignatura> => {
}))
}
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas/')({
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
component: AsignaturasPage,
})
@@ -306,6 +306,7 @@ function AsignaturasPage() {
</TableBody>
</Table>
</div>
<Outlet />
</div>
)
}