From 8d20fd449226b989077093cde0c6edaa04619eb2 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 29 Dec 2025 13:14:25 -0600 Subject: [PATCH] =?UTF-8?q?primera=20versi=C3=B3n=20de=20stepper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/planes/_lista/nuevo.tsx | 1001 +++++++++++++++++++++++++++- 1 file changed, 992 insertions(+), 9 deletions(-) diff --git a/src/routes/planes/_lista/nuevo.tsx b/src/routes/planes/_lista/nuevo.tsx index b3bd717..2474b22 100644 --- a/src/routes/planes/_lista/nuevo.tsx +++ b/src/routes/planes/_lista/nuevo.tsx @@ -1,29 +1,1012 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' +import * as Icons from 'lucide-react' +import { useMemo, useState } from 'react' -import CheckoutStepper from '@/components/planes/exampleStepper' -import { Dialog, DialogContent } from '@/components/ui/dialog' +import { defineStepper } from '@/components/stepper' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' export const Route = createFileRoute('/planes/_lista/nuevo')({ component: NuevoPlanModal, }) +// Tipos del wizard (mock frontend) +type TipoCiclo = 'SEMESTRE' | 'CUATRIMESTRE' | 'TRIMESTRE' +type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO' +type SubModoClonado = 'INTERNO' | 'TRADICIONAL' + +type PlanPreview = { + nombrePlan: string + nivel: string + tipoCiclo: TipoCiclo + numCiclos: number + numMateriasAprox?: number + secciones?: Array<{ id: string; titulo: string; resumen: string }> +} + +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 + } + resumen: { previewPlan?: PlanPreview } + isLoading: boolean + errorMessage: string | null +} + +// Mock de permisos/rol +const auth_get_current_user_role = () => 'JEFE_CARRERA' as const + +// Mock catálogos +const FACULTADES = [ + { id: 'ing', nombre: 'Facultad de Ingeniería' }, + { id: 'med', nombre: 'Facultad de Medicina' }, + { id: 'neg', nombre: 'Facultad de Negocios' }, +] + +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' }, +] + +const NIVELES = ['Licenciatura', 'Especialidad', 'Maestría', 'Doctorado'] +const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [ + { value: 'SEMESTRE', label: 'Semestre' }, + { value: 'CUATRIMESTRE', label: 'Cuatrimestre' }, + { value: 'TRIMESTRE', label: 'Trimestre' }, +] + +// Mock planes existentes para clonado interno +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', + }, +] + +// Definición de pasos con wrapper +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' }, +) + function NuevoPlanModal() { const navigate = useNavigate() + const [wizard, setWizard] = useState({ + 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 role = auth_get_current_user_role() + const handleClose = () => { - // Navegamos de regreso a la lista manteniendo el scroll donde estaba navigate({ to: '/planes', resetScroll: false }) } + // Derivados + 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 + // Reglas mínimas: Word + al menos un Excel + 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 }, + })) + } + + const crearPlan = async () => { + setWizard((w) => ({ ...w, isLoading: true, errorMessage: null })) + await new Promise((r) => setTimeout(r, 900)) + // Elegimos un id ficticio distinto según modo + 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 ( !open && handleClose()}> - {/* DialogContent es la "caja" blanca del modal. - Le damos un ancho máximo un poco mayor a tu stepper (que mide 450px) - para que quepa cómodamente. - */} - - + + + Nuevo plan de estudios + + + {role !== 'JEFE_CARRERA' ? ( +
+ + + + + Sin permisos + + + No tienes permisos para crear planes de estudio. + + + + + + +
+ ) : ( +
+ + {({ methods }) => ( + <> + {/* Header + navegación */} + + + 1. Método + + Selecciona cómo crearás el plan + + + + + 2. Datos básicos + + + Nombre, carrera, nivel y ciclos + + + + 3. Detalles + + IA, clonado o archivos + + + + 4. Resumen + + Confirma y crea el plan + + + + + {/* Info de paso actual */} +
+ + Paso {Wizard.utils.getIndex(methods.current.id) + 1} de{' '} + {Wizard.steps.length} + +
+ + {/* Panel activo (solo uno visible) */} +
+
+ {Wizard.utils.getIndex(methods.current.id) === 0 && ( + + + + )} + {Wizard.utils.getIndex(methods.current.id) === 1 && ( + + + + )} + {Wizard.utils.getIndex(methods.current.id) === 2 && ( + + + + )} + {Wizard.utils.getIndex(methods.current.id) === 3 && ( + + + + )} +
+
+ + {/* Controles */} + + {wizard.errorMessage && ( + + {wizard.errorMessage} + + )} + + {Wizard.utils.getIndex(methods.current.id) < + Wizard.steps.length - 1 ? ( + + ) : ( + + )} + + + )} +
+
+ )}
) } + +// Paso 1: selección de modo +function PasoModo({ + wizard, + onChange, +}: { + wizard: NewPlanWizardState + onChange: React.Dispatch> +}) { + const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m + const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s + return ( +
+ + onChange((w) => ({ + ...w, + modoCreacion: 'MANUAL', + subModoClonado: undefined, + })) + } + role="button" + tabIndex={0} + > + + + Manual + + Plan vacío con estructura mínima. + + + + + onChange((w) => ({ + ...w, + modoCreacion: 'IA', + subModoClonado: undefined, + })) + } + role="button" + tabIndex={0} + > + + + Con IA + + + Borrador completo a partir de datos base. + + + + + onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))} + role="button" + tabIndex={0} + > + + + Clonado + + Desde un plan existente o archivos. + + {wizard.modoCreacion === 'CLONADO' && ( + + + + + )} + +
+ ) +} + +// Paso 2: datos básicos +function PasoBasicos({ + wizard, + onChange, + carrerasFiltradas, +}: { + wizard: NewPlanWizardState + onChange: React.Dispatch> + carrerasFiltradas: typeof CARRERAS +}) { + return ( +
+
+ + ) => + onChange((w) => ({ + ...w, + datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value }, + })) + } + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + ) => + onChange((w) => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + numCiclos: Number(e.target.value || 0), + }, + })) + } + /> +
+
+ ) +} + +// Paso 3: detalles por modo +function PasoDetalles({ + wizard, + onChange, + onGenerarIA, + isLoading, +}: { + wizard: NewPlanWizardState + onChange: React.Dispatch> + onGenerarIA: () => void + isLoading: boolean +}) { + if (wizard.modoCreacion === 'MANUAL') { + return ( + + + Creación manual + + Se creará un plan en blanco con estructura mínima. + + + + ) + } + + if (wizard.modoCreacion === 'IA') { + return ( +
+
+ +