Separación vista/lógica del wizard de creación de plan

This commit is contained in:
2026-01-05 13:24:48 -06:00
parent d0e095c979
commit a65e34b41c
22 changed files with 1384 additions and 1695 deletions

View File

@@ -0,0 +1,205 @@
import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel'
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
import { WizardControls } from '@/components/planes/wizard/WizardControls'
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
// Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
const Wizard = defineStepper(
{
id: 'modo',
title: 'Método',
description: 'Selecciona cómo crearás el plan',
},
{
id: 'basicos',
title: 'Datos básicos',
description: 'Nombre, carrera, nivel y ciclos',
},
{ id: 'detalles', title: 'Detalles', description: 'IA, clonado o archivos' },
{ id: 'resumen', title: 'Resumen', description: 'Confirma y crea el plan' },
)
export default function NuevoPlanModalContainer() {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const {
wizard,
setWizard,
carrerasFiltradas,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
} = useNuevoPlanWizard()
const handleClose = () => {
navigate({ to: '/planes', resetScroll: false })
}
const crearPlan = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
}
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => {
e.preventDefault()
}}
>
{role !== 'JEFE_CARRERA' ? (
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<div className="flex-1 p-6">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
No tienes permisos para crear planes de estudio.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<button
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
onClick={handleClose}
>
Volver
</button>
</CardContent>
</Card>
</div>
</>
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
const totalSteps = Wizard.steps.length
const nextStep = Wizard.steps[currentIndex]
return (
<>
<WizardHeader
currentIndex={currentIndex}
totalSteps={totalSteps}
currentTitle={methods.current.title}
currentDescription={methods.current.description}
nextTitle={nextStep?.title}
onClose={handleClose}
Wizard={Wizard}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoModoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm
wizard={wizard}
onChange={setWizard}
carrerasFiltradas={carrerasFiltradas}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<div className="flex-none border-t bg-white p-6">
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
onCreate={crearPlan}
disablePrev={
Wizard.utils.getIndex(methods.current.id) === 0 ||
wizard.isLoading
}
disableNext={
wizard.isLoading ||
(Wizard.utils.getIndex(methods.current.id) === 0 &&
!canContinueDesdeModo) ||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
!canContinueDesdeBasicos) ||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
!canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={
Wizard.utils.getIndex(methods.current.id) >=
Wizard.steps.length - 1
}
/>
</Wizard.Stepper.Controls>
</div>
</>
)
}}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,56 @@
import type { TipoCiclo } from "./types";
export const FACULTADES = [
{ id: "ing", nombre: "Facultad de Ingeniería" },
{
id: "med",
nombre: "Facultad de Medicina en medicina en medicina en medicina",
},
{ id: "neg", nombre: "Facultad de Negocios" },
];
export const CARRERAS = [
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
];
export const NIVELES = [
"Licenciatura",
"Especialidad",
"Maestría",
"Doctorado",
];
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
{ value: "SEMESTRE", label: "Semestre" },
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
{ value: "TRIMESTRE", label: "Trimestre" },
];
export const PLANES_EXISTENTES = [
{
id: "plan-2021-sis",
nombre: "ISC 2021",
estado: "Aprobado",
anio: 2021,
facultadId: "ing",
carreraId: "sis",
},
{
id: "plan-2020-ind",
nombre: "I. Industrial 2020",
estado: "Aprobado",
anio: 2020,
facultadId: "ing",
carreraId: "ind",
},
{
id: "plan-2019-med",
nombre: "Medicina 2019",
estado: "Vigente",
anio: 2019,
facultadId: "med",
carreraId: "medico",
},
];

View File

@@ -0,0 +1,102 @@
import { useMemo, useState } from "react";
import { CARRERAS } from "../catalogs";
import type { NewPlanWizardState, PlanPreview } from "../types";
export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({
step: 1,
modoCreacion: null,
datosBasicos: {
nombrePlan: "",
carreraId: "",
facultadId: "",
nivel: "",
tipoCiclo: "SEMESTRE",
numCiclos: 8,
},
clonInterno: { planOrigenId: null },
clonTradicional: {
archivoWordPlanId: null,
archivoMapaExcelId: null,
archivoMateriasExcelId: null,
},
iaConfig: {
descripcionEnfoque: "",
poblacionObjetivo: "",
notasAdicionales: "",
archivosReferencia: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
});
const carrerasFiltradas = useMemo(() => {
const fac = wizard.datosBasicos.facultadId;
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS;
}, [wizard.datosBasicos.facultadId]);
const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carreraId &&
!!wizard.datosBasicos.facultadId &&
!!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos > 0;
const canContinueDesdeDetalles = (() => {
if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque;
}
if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") {
return !!wizard.clonInterno?.planOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
const t = wizard.clonTradicional;
if (!t) return false;
const tieneWord = !!t.archivoWordPlanId;
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
!!t.archivoMateriasExcelId;
return tieneWord && tieneAlMenosUnExcel;
}
}
return false;
})();
const generarPreviewIA = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
await new Promise((r) => setTimeout(r, 800));
const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel || "Licenciatura",
tipoCiclo: wizard.datosBasicos.tipoCiclo,
numCiclos: wizard.datosBasicos.numCiclos,
numMateriasAprox: wizard.datosBasicos.numCiclos * 6,
secciones: [
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
],
};
setWizard((w) => ({
...w,
isLoading: false,
resumen: { previewPlan: preview },
}));
};
return {
wizard,
setWizard,
carrerasFiltradas,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
};
}

View File

@@ -0,0 +1,41 @@
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE";
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
export type PlanPreview = {
nombrePlan: string;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
numMateriasAprox?: number;
secciones?: Array<{ id: string; titulo: string; resumen: string }>;
};
export type NewPlanWizardState = {
step: 1 | 2 | 3 | 4;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
datosBasicos: {
nombrePlan: string;
carreraId: string;
facultadId: string;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
};
clonInterno?: { planOrigenId: string | null };
clonTradicional?: {
archivoWordPlanId: string | null;
archivoMapaExcelId: string | null;
archivoMateriasExcelId: string | null;
};
iaConfig?: {
descripcionEnfoque: string;
poblacionObjetivo: string;
notasAdicionales: string;
archivosReferencia: Array<string>;
};
resumen: { previewPlan?: PlanPreview };
isLoading: boolean;
errorMessage: string | null;
};