From d0e095c9792e49df6b309d90a239da2ef7494b00 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 5 Jan 2026 10:50:36 -0600 Subject: [PATCH] =?UTF-8?q?vista=20de=20wizard=20de=20creaci=C3=B3n=20de?= =?UTF-8?q?=20materia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routeTree.gen.ts | 75 ++ .../asignaturas/$asignaturaId/route.tsx | 9 + src/routes/asignaturas/_lista/nueva.tsx | 1083 +++++++++++++++++ src/routes/asignaturas/_lista/route.tsx | 15 + src/routes/planes/_lista/nuevo.tsx | 91 +- 5 files changed, 1236 insertions(+), 37 deletions(-) create mode 100644 src/routes/asignaturas/$asignaturaId/route.tsx create mode 100644 src/routes/asignaturas/_lista/nueva.tsx create mode 100644 src/routes/asignaturas/_lista/route.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0858c7d..8b40112 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,7 +17,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route' import { Route as PlanesPlanIdRouteRouteImport } from './routes/planes/$planId/route' +import { Route as AsignaturasListaRouteRouteImport } from './routes/asignaturas/_lista/route' +import { Route as AsignaturasAsignaturaIdRouteRouteImport } from './routes/asignaturas/$asignaturaId/route' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' +import { Route as AsignaturasListaNuevaRouteImport } from './routes/asignaturas/_lista/nueva' const Stepper2Route = Stepper2RouteImport.update({ id: '/stepper2', @@ -59,11 +62,27 @@ const PlanesPlanIdRouteRoute = PlanesPlanIdRouteRouteImport.update({ path: '/planes/$planId', getParentRoute: () => rootRouteImport, } as any) +const AsignaturasListaRouteRoute = AsignaturasListaRouteRouteImport.update({ + id: '/asignaturas/_lista', + path: '/asignaturas', + getParentRoute: () => rootRouteImport, +} as any) +const AsignaturasAsignaturaIdRouteRoute = + AsignaturasAsignaturaIdRouteRouteImport.update({ + id: '/asignaturas/$asignaturaId', + path: '/asignaturas/$asignaturaId', + getParentRoute: () => rootRouteImport, + } as any) const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({ id: '/nuevo', path: '/nuevo', getParentRoute: () => PlanesListaRouteRoute, } as any) +const AsignaturasListaNuevaRoute = AsignaturasListaNuevaRouteImport.update({ + id: '/nueva', + path: '/nueva', + getParentRoute: () => AsignaturasListaRouteRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -71,9 +90,12 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/stepper': typeof StepperRoute '/stepper2': typeof Stepper2Route + '/asignaturas/$asignaturaId': typeof AsignaturasAsignaturaIdRouteRoute + '/asignaturas': typeof AsignaturasListaRouteRouteWithChildren '/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/asignaturas/nueva': typeof AsignaturasListaNuevaRoute '/planes/nuevo': typeof PlanesListaNuevoRoute } export interface FileRoutesByTo { @@ -82,9 +104,12 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/stepper': typeof StepperRoute '/stepper2': typeof Stepper2Route + '/asignaturas/$asignaturaId': typeof AsignaturasAsignaturaIdRouteRoute + '/asignaturas': typeof AsignaturasListaRouteRouteWithChildren '/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/asignaturas/nueva': typeof AsignaturasListaNuevaRoute '/planes/nuevo': typeof PlanesListaNuevoRoute } export interface FileRoutesById { @@ -94,9 +119,12 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/stepper': typeof StepperRoute '/stepper2': typeof Stepper2Route + '/asignaturas/$asignaturaId': typeof AsignaturasAsignaturaIdRouteRoute + '/asignaturas/_lista': typeof AsignaturasListaRouteRouteWithChildren '/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes/_lista': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute + '/asignaturas/_lista/nueva': typeof AsignaturasListaNuevaRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute } export interface FileRouteTypes { @@ -107,9 +135,12 @@ export interface FileRouteTypes { | '/login' | '/stepper' | '/stepper2' + | '/asignaturas/$asignaturaId' + | '/asignaturas' | '/planes/$planId' | '/planes' | '/demo/tanstack-query' + | '/asignaturas/nueva' | '/planes/nuevo' fileRoutesByTo: FileRoutesByTo to: @@ -118,9 +149,12 @@ export interface FileRouteTypes { | '/login' | '/stepper' | '/stepper2' + | '/asignaturas/$asignaturaId' + | '/asignaturas' | '/planes/$planId' | '/planes' | '/demo/tanstack-query' + | '/asignaturas/nueva' | '/planes/nuevo' id: | '__root__' @@ -129,9 +163,12 @@ export interface FileRouteTypes { | '/login' | '/stepper' | '/stepper2' + | '/asignaturas/$asignaturaId' + | '/asignaturas/_lista' | '/planes/$planId' | '/planes/_lista' | '/demo/tanstack-query' + | '/asignaturas/_lista/nueva' | '/planes/_lista/nuevo' fileRoutesById: FileRoutesById } @@ -141,6 +178,8 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute StepperRoute: typeof StepperRoute Stepper2Route: typeof Stepper2Route + AsignaturasAsignaturaIdRouteRoute: typeof AsignaturasAsignaturaIdRouteRoute + AsignaturasListaRouteRoute: typeof AsignaturasListaRouteRouteWithChildren PlanesPlanIdRouteRoute: typeof PlanesPlanIdRouteRoute PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute @@ -204,6 +243,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlanesPlanIdRouteRouteImport parentRoute: typeof rootRouteImport } + '/asignaturas/_lista': { + id: '/asignaturas/_lista' + path: '/asignaturas' + fullPath: '/asignaturas' + preLoaderRoute: typeof AsignaturasListaRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/asignaturas/$asignaturaId': { + id: '/asignaturas/$asignaturaId' + path: '/asignaturas/$asignaturaId' + fullPath: '/asignaturas/$asignaturaId' + preLoaderRoute: typeof AsignaturasAsignaturaIdRouteRouteImport + parentRoute: typeof rootRouteImport + } '/planes/_lista/nuevo': { id: '/planes/_lista/nuevo' path: '/nuevo' @@ -211,9 +264,29 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlanesListaNuevoRouteImport parentRoute: typeof PlanesListaRouteRoute } + '/asignaturas/_lista/nueva': { + id: '/asignaturas/_lista/nueva' + path: '/nueva' + fullPath: '/asignaturas/nueva' + preLoaderRoute: typeof AsignaturasListaNuevaRouteImport + parentRoute: typeof AsignaturasListaRouteRoute + } } } +interface AsignaturasListaRouteRouteChildren { + AsignaturasListaNuevaRoute: typeof AsignaturasListaNuevaRoute +} + +const AsignaturasListaRouteRouteChildren: AsignaturasListaRouteRouteChildren = { + AsignaturasListaNuevaRoute: AsignaturasListaNuevaRoute, +} + +const AsignaturasListaRouteRouteWithChildren = + AsignaturasListaRouteRoute._addFileChildren( + AsignaturasListaRouteRouteChildren, + ) + interface PlanesListaRouteRouteChildren { PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute } @@ -231,6 +304,8 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, StepperRoute: StepperRoute, Stepper2Route: Stepper2Route, + AsignaturasAsignaturaIdRouteRoute: AsignaturasAsignaturaIdRouteRoute, + AsignaturasListaRouteRoute: AsignaturasListaRouteRouteWithChildren, PlanesPlanIdRouteRoute: PlanesPlanIdRouteRoute, PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren, DemoTanstackQueryRoute: DemoTanstackQueryRoute, diff --git a/src/routes/asignaturas/$asignaturaId/route.tsx b/src/routes/asignaturas/$asignaturaId/route.tsx new file mode 100644 index 0000000..e314491 --- /dev/null +++ b/src/routes/asignaturas/$asignaturaId/route.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/asignaturas/$asignaturaId')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/asignaturas/$asignaturaId"!
+} diff --git a/src/routes/asignaturas/_lista/nueva.tsx b/src/routes/asignaturas/_lista/nueva.tsx new file mode 100644 index 0000000..f0dc9bd --- /dev/null +++ b/src/routes/asignaturas/_lista/nueva.tsx @@ -0,0 +1,1083 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import * as Icons from 'lucide-react' +import { useState } from 'react' + +import { CircularProgress } from '@/components/CircularProgress' +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' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' + +export const Route = createFileRoute('/asignaturas/_lista/nueva')({ + component: NuevaMateriaModal, +}) + +// --- TIPOS Y ESTADO --- + +type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO' +type SubModoClonado = 'INTERNO' | 'TRADICIONAL' +type TipoMateria = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO' + +type MateriaPreview = { + nombre: string + objetivo: string + unidades: number + bibliografiaCount: number +} + +type NewSubjectWizardState = { + step: 1 | 2 | 3 | 4 + // planId: string + modoCreacion: ModoCreacion | null + subModoClonado?: SubModoClonado + datosBasicos: { + nombre: string + clave?: string + tipo: TipoMateria + creditos: number + horasSemana?: number + estructuraId: string + } + clonInterno?: { + facultadId?: string + carreraId?: string + planOrigenId?: string + materiaOrigenId?: string | null + } + clonTradicional?: { + archivoWordMateriaId: string | null + archivosAdicionalesIds: Array + } + iaConfig?: { + descripcionEnfoque: string + notasAdicionales: string + archivosExistentesIds: Array + } + resumen: { + previewMateria?: MateriaPreview + } + isLoading: boolean + errorMessage: string | null +} + +// --- MOCKS (Hardcoded Data) --- + +const auth_get_current_user_role = () => 'JEFE_CARRERA' as const + +const ESTRUCTURAS_SEP = [ + { id: 'sep-lic-2025', label: 'Licenciatura SEP v2025' }, + { id: 'sep-pos-2023', label: 'Posgrado SEP v2023' }, + { id: 'ulsa-int-2024', label: 'Estándar Interno ULSA 2024' }, +] + +const TIPOS_MATERIA: Array<{ value: TipoMateria; label: string }> = [ + { value: 'OBLIGATORIA', label: 'Obligatoria' }, + { value: 'OPTATIVA', label: 'Optativa' }, + { value: 'TRONCAL', label: 'Troncal / Eje común' }, + { value: 'OTRO', label: 'Otro' }, +] + +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 PLANES_MOCK = [ + { id: 'p1', nombre: 'Plan 2010 Sistemas', carreraId: 'sis' }, + { id: 'p2', nombre: 'Plan 2016 Sistemas', carreraId: 'sis' }, + { id: 'p3', nombre: 'Plan 2015 Industrial', carreraId: 'ind' }, +] + +const MATERIAS_MOCK = [ + { + id: 'm1', + nombre: 'Programación Orientada a Objetos', + creditos: 8, + clave: 'POO-101', + }, + { id: 'm2', nombre: 'Cálculo Diferencial', creditos: 6, clave: 'MAT-101' }, + { id: 'm3', nombre: 'Ética Profesional', creditos: 4, clave: 'HUM-302' }, + { + id: 'm4', + nombre: 'Bases de Datos Avanzadas', + creditos: 8, + clave: 'BD-201', + }, +] + +const ARCHIVOS_SISTEMA_MOCK = [ + { id: 'doc1', name: 'Sílabo_Base_Ingenieria.pdf' }, + { id: 'doc2', name: 'Competencias_Egreso_2025.docx' }, + { id: 'doc3', name: 'Reglamento_Academico.pdf' }, +] + +// --- STEPPER CONFIG --- + +const Wizard = defineStepper( + { + id: 'metodo', + title: 'Método', + description: 'Manual, IA o Clonado', + }, + { + id: 'basicos', + title: 'Datos básicos', + description: 'Nombre y estructura', + }, + { + id: 'configuracion', + title: 'Configuración', + description: 'Detalles según modo', + }, + { + id: 'resumen', + title: 'Resumen', + description: 'Confirmar creación', + }, +) + +// --- COMPONENTE PRINCIPAL --- + +function NuevaMateriaModal() { + const navigate = useNavigate() + // const { planId } = Route.useParams() + const role = auth_get_current_user_role() + + const [wizard, setWizard] = useState({ + step: 1, + // planId: planId, + modoCreacion: null, + datosBasicos: { + nombre: '', + clave: '', + tipo: 'OBLIGATORIA', + creditos: 0, + horasSemana: 0, + estructuraId: '', + }, + clonInterno: {}, + clonTradicional: { + archivoWordMateriaId: null, + archivosAdicionalesIds: [], + }, + iaConfig: { + descripcionEnfoque: '', + notasAdicionales: '', + archivosExistentesIds: [], + }, + resumen: {}, + isLoading: false, + errorMessage: null, + }) + + const handleClose = () => { + // Redirige a la pestaña de materias del plan + navigate({ to: `/planes`, resetScroll: false }) + } + + // --- Validaciones --- + const canContinueDesdeMetodo = + wizard.modoCreacion === 'MANUAL' || + wizard.modoCreacion === 'IA' || + (wizard.modoCreacion === 'CLONADO' && !!wizard.subModoClonado) + + const canContinueDesdeBasicos = + !!wizard.datosBasicos.nombre && + wizard.datosBasicos.creditos > 0 && + !!wizard.datosBasicos.estructuraId + + const canContinueDesdeConfig = (() => { + 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?.materiaOrigenId + } + if (wizard.subModoClonado === 'TRADICIONAL') { + return !!wizard.clonTradicional?.archivoWordMateriaId + } + } + return false + })() + + // --- Simulaciones de API --- + const simularGeneracionIA = async () => { + setWizard((w) => ({ ...w, isLoading: true })) + await new Promise((r) => setTimeout(r, 1500)) + setWizard((w) => ({ + ...w, + isLoading: false, + resumen: { + previewMateria: { + nombre: w.datosBasicos.nombre, + objetivo: + 'Aplicar los fundamentos teóricos para la resolución de problemas...', + unidades: 5, + bibliografiaCount: 3, + }, + }, + })) + } + + const crearMateria = async () => { + setWizard((w) => ({ ...w, isLoading: true })) + await new Promise((r) => setTimeout(r, 1000)) + // Aquí iría la llamada real al backend + // Toast de éxito... + handleClose() + } + + return ( + !open && handleClose()}> + e.preventDefault()} + > + {role !== 'JEFE_CARRERA' ? ( + + ) : ( + + {({ methods }) => { + const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1 + const totalSteps = Wizard.steps.length + const nextStep = Wizard.steps[currentIndex] + + return ( + <> + {/* --- HEADER FIJO --- */} +
+
+ + Nueva Materia + + +
+ +
+ {/* VISTA MÓVIL (< 640px) */} +
+
+ +
+

+ +

+ {nextStep ? ( +

+ Siguiente: {nextStep.title} +

+ ) : ( +

+ ¡Último paso! +

+ )} +
+
+
+ + {/* VISTA DESKTOP (>= 640px) */} +
+ + {Wizard.steps.map((step) => ( + + + + + + ))} + +
+
+
+ + {/* --- CONTENIDO SCROLLEABLE --- */} +
+
+ {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 && ( + + + + )} +
+
+ + {/* --- FOOTER FIJO --- */} +
+ +
+ {wizard.errorMessage && ( + + {wizard.errorMessage} + + )} +
+ +
+ + + {Wizard.utils.getIndex(methods.current.id) < + Wizard.steps.length - 1 ? ( + + ) : ( + + )} +
+
+
+ + ) + }} +
+ )} +
+
+ ) +} + +// --- SUB-COMPONENTES DE PASOS --- + +function PasoMetodo({ + wizard, + onChange, +}: { + wizard: NewSubjectWizardState + onChange: React.Dispatch> +}) { + const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m + const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s + + return ( +
+ {/* 1. MANUAL */} + + onChange((w) => ({ + ...w, + modoCreacion: 'MANUAL', + subModoClonado: undefined, + })) + } + role="button" + tabIndex={0} + > + + + Manual + + Materia vacía con estructura base. + + + + {/* 2. CON IA */} + + onChange((w) => ({ + ...w, + modoCreacion: 'IA', + subModoClonado: undefined, + })) + } + role="button" + tabIndex={0} + > + + + Con IA + + Generar contenido automático. + + + + {/* 3. CLONADO */} + onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))} + role="button" + tabIndex={0} + > + + + Clonado + + De otra materia o archivo Word. + + {wizard.modoCreacion === 'CLONADO' && ( + +
+ {/* Opción Interna */} +
{ + 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' + }`} + > + +
+ Del sistema + + Buscar en otros planes + +
+
+ + {/* Opción Tradicional */} +
{ + 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' + }`} + > + +
+ Desde archivos + + Subir Word existente + +
+
+
+
+ )} +
+
+ ) +} + +function PasoBasicos({ + wizard, + onChange, +}: { + wizard: NewSubjectWizardState + onChange: React.Dispatch> +}) { + return ( +
+
+ + + onChange((w) => ({ + ...w, + datosBasicos: { ...w.datosBasicos, nombre: e.target.value }, + })) + } + /> +
+ +
+ + + onChange((w) => ({ + ...w, + datosBasicos: { ...w.datosBasicos, clave: e.target.value }, + })) + } + /> +
+ +
+ + +
+ +
+ + + onChange((w) => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + creditos: Number(e.target.value || 0), + }, + })) + } + /> +
+ +
+ + + onChange((w) => ({ + ...w, + datosBasicos: { + ...w.datosBasicos, + horasSemana: Number(e.target.value || 0), + }, + })) + } + /> +
+ +
+ + +

+ Define los campos requeridos (ej. Objetivos, Temario, Evaluación). +

+
+
+ ) +} + +function PasoConfiguracion({ + wizard, + onChange, + onGenerarIA, +}: { + wizard: NewSubjectWizardState + onChange: React.Dispatch> + onGenerarIA: () => void +}) { + if (wizard.modoCreacion === 'MANUAL') { + return ( + + + Configuración Manual + + La materia se creará vacía. Podrás editar el contenido detallado en + la siguiente pantalla. + + + + ) + } + + if (wizard.modoCreacion === 'IA') { + return ( +
+
+ +