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

@@ -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
}