2 Commits

35 changed files with 2587 additions and 2773 deletions

View File

@@ -0,0 +1,155 @@
import type {
NewSubjectWizardState,
TipoAsignatura,
} from '@/features/asignaturas/new/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ESTRUCTURAS_SEP,
TIPOS_MATERIA,
} from '@/features/asignaturas/new/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombre">Nombre de la asignatura</Label>
<Input
id="nombre"
placeholder="Ej. Matemáticas Discretas"
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="clave">Clave (Opcional)</Label>
<Input
id="clave"
placeholder="Ej. MAT-101"
value={wizard.datosBasicos.clave || ''}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
}))
}
/>
</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 },
}))
}
>
<SelectTrigger
id="tipo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="number"
min={0}
value={wizard.datosBasicos.creditos}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horas">Horas / Semana</Label>
<Input
id="horas"
type="number"
min={0}
value={wizard.datosBasicos.horasSemana || 0}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasSemana: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}))
}
>
<SelectTrigger
id="estructura"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{ESTRUCTURAS_SEP.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,286 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
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 {
ARCHIVOS_SISTEMA_MOCK,
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/new/catalogs'
export function PasoConfiguracionPanel({
wizard,
onChange,
onGenerarIA,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void
}) {
if (wizard.modoCreacion === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Configuración Manual</CardTitle>
<CardDescription>
La asignatura se creará vacía. Podrás editar el contenido detallado
en la siguiente pantalla.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.modoCreacion === 'IA') {
return (
<div className="grid gap-4">
<div className="grid gap-1">
<Label>Descripción del enfoque</Label>
<Textarea
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
value={wizard.iaConfig?.descripcionEnfoque}
onChange={(e) =>
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoque: e.target.value,
},
}))
}
className="min-h-[100px]"
/>
</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) => ({
...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>
)}
</div>
)
}
if (wizard.subModoClonado === 'INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label>Facultad</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, facultadId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Plan</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, planOrigenId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{PLANES_MOCK.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buscar</Label>
<Input placeholder="Nombre..." />
</div>
</div>
<div className="grid max-h-[300px] gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
onClick={() =>
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'
: ''
}`}
>
<div>
<div className="font-medium">{m.nombre}</div>
<div className="text-muted-foreground text-xs">
{m.clave} {m.creditos} créditos
</div>
</div>
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
)}
</div>
))}
</div>
</div>
)
}
if (wizard.subModoClonado === 'TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
<h3 className="mb-1 text-sm font-medium">
Sube el Word de la asignatura
</h3>
<p className="text-muted-foreground mb-4 text-xs">
Arrastra el archivo o haz clic para buscar (.doc, .docx)
</p>
<Input
type="file"
accept=".doc,.docx"
className="mx-auto max-w-xs"
onChange={(e) =>
onChange((w) => ({
...w,
clonTradicional: {
...w.clonTradicional!,
archivoWordAsignaturaId:
e.target.files?.[0]?.name || 'mock_file',
},
}))
}
/>
</div>
{wizard.clonTradicional?.archivoWordAsignaturaId && (
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
<Icons.FileText className="h-4 w-4" />
Archivo cargado listo para procesar.
</div>
)}
</div>
)
}
return null
}

View File

@@ -0,0 +1,135 @@
import * as Icons from 'lucide-react'
import type {
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/new/types'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export function PasoMetodoCardGroup({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
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,
}))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Pencil className="text-primary h-5 w-5" /> Manual
</CardTitle>
<CardDescription>
Asignatura vacía con estructura base.
</CardDescription>
</CardHeader>
</Card>
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'IA',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Sparkles className="text-primary h-5 w-5" /> Con IA
</CardTitle>
<CardDescription>Generar contenido automático.</CardDescription>
</CardHeader>
</Card>
<Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Copy className="text-primary h-5 w-5" /> Clonado
</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>
</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>
</div>
</CardContent>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
return (
<Card>
<CardHeader>
<CardTitle>Resumen de creación</CardTitle>
<CardDescription>
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>
</div>
<div>
<span className="text-muted-foreground">Tipo:</span>
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
</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>
</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>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export function StepWithTooltip({
title,
desc,
}: {
title: string
desc: string
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<TooltipProvider delayDuration={0}>
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger asChild>
<span
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
onClick={(e) => {
e.stopPropagation()
setIsOpen((prev) => !prev)
}}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[200px] text-xs">
<p>{desc}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,39 @@
import * as Icons from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
export function VistaSinPermisos({ onClose }: { onClose: () => void }) {
return (
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nueva Asignatura</DialogTitle>
</DialogHeader>
<div className="flex-1 p-6">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<Icons.ShieldAlert className="h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
Solo el Jefe de Carrera puede crear asignaturas.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<Button variant="secondary" onClick={onClose}>
Volver
</Button>
</CardContent>
</Card>
</div>
</>
)
}

View File

@@ -0,0 +1,66 @@
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import { Button } from '@/components/ui/button'
export function WizardControls({
Wizard,
methods,
wizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
onCreate,
}: {
Wizard: any
methods: any
wizard: NewSubjectWizardState
canContinueDesdeMetodo: boolean
canContinueDesdeBasicos: boolean
canContinueDesdeConfig: boolean
onCreate: () => void
}) {
const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1
return (
<div className="flex-none border-t bg-white p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
{wizard.errorMessage && (
<span className="text-destructive text-sm font-medium">
{wizard.errorMessage}
</span>
)}
</div>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
</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>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import * as Icons from 'lucide-react'
import { StepWithTooltip } from './StepWithTooltip'
import { CircularProgress } from '@/components/CircularProgress'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
export function WizardHeader({
title,
Wizard,
methods,
}: {
title: string
Wizard: any
methods: any
}) {
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
const totalSteps = Wizard.steps.length
const nextStep = Wizard.steps[currentIndex]
return (
<div className="z-10 flex-none border-b bg-white">
<div className="flex items-center justify-between p-6 pb-4">
<DialogHeader className="p-0">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{methods.onClose && (
<button
onClick={methods.onClose}
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
>
<Icons.X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</button>
)}
</div>
<div className="px-6 pb-6">
<div className="block sm:hidden">
<div className="flex items-center gap-5">
<CircularProgress current={currentIndex} total={totalSteps} />
<div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip
title={methods.current.title}
desc={methods.current.description}
/>
</h2>
{nextStep ? (
<p className="text-sm text-slate-400">
Siguiente: {nextStep.title}
</p>
) : (
<p className="text-sm font-medium text-green-500">
¡Último paso!
</p>
)}
</div>
</div>
</div>
<div className="hidden sm:block">
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{Wizard.steps.map((step: any) => (
<Wizard.Stepper.Step
key={step.id}
of={step.id}
className="whitespace-nowrap"
>
<Wizard.Stepper.Title>
<StepWithTooltip title={step.title} desc={step.description} />
</Wizard.Stepper.Title>
</Wizard.Stepper.Step>
))}
</Wizard.Stepper.Navigation>
</div>
</div>
</div>
)
}

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,163 +0,0 @@
import { defineStepper } from '@stepperize/react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
// import './App.css'
const { useStepper, steps, utils } = defineStepper(
{
id: 'shipping',
title: 'Shipping',
description: 'Enter your shipping details',
},
{
id: 'payment',
title: 'Payment',
description: 'Enter your payment details',
},
{ id: 'complete', title: 'Complete', description: 'Checkout complete' },
)
function App() {
const stepper = useStepper()
const currentIndex = utils.getIndex(stepper.current.id)
return (
<div className="w-[450px] space-y-6 rounded-lg border p-6">
<div className="flex justify-between">
<h2 className="text-lg font-medium">Checkout</h2>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
Step {currentIndex + 1} of {steps.length}
</span>
<div />
</div>
</div>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex flex-shrink-0 items-center gap-4">
<Button
type="button"
role="tab"
variant={index <= currentIndex ? 'default' : 'secondary'}
aria-current={
stepper.current.id === step.id ? 'step' : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full"
onClick={() => stepper.goTo(step.id)}
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < currentIndex ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
</React.Fragment>
))}
</ol>
</nav>
<div className="space-y-4">
{stepper.switch({
shipping: () => <ShippingComponent />,
payment: () => <PaymentComponent />,
complete: () => <CompleteComponent />,
})}
{!stepper.isLast ? (
<div className="flex justify-end gap-4">
<Button
variant="secondary"
onClick={stepper.prev}
disabled={stepper.isFirst}
>
Back
</Button>
<Button onClick={stepper.next}>
{stepper.isLast ? 'Complete' : 'Next'}
</Button>
</div>
) : (
<Button onClick={stepper.reset}>Reset</Button>
)}
</div>
</div>
)
}
const ShippingComponent = () => {
return (
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor="name" className="text-start text-sm font-medium">
Name
</label>
<Input id="name" placeholder="John Doe" className="w-full" />
</div>
<div className="grid gap-2">
<label htmlFor="address" className="text-start text-sm font-medium">
Address
</label>
<Textarea
id="address"
placeholder="123 Main St, Anytown USA"
className="w-full"
/>
</div>
</div>
)
}
const PaymentComponent = () => {
return (
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor="card-number" className="text-start text-sm font-medium">
Card Number
</label>
<Input
id="card-number"
placeholder="4111 1111 1111 1111"
className="w-full"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<label
htmlFor="expiry-date"
className="text-start text-sm font-medium"
>
Expiry Date
</label>
<Input id="expiry-date" placeholder="MM/YY" className="w-full" />
</div>
<div className="grid gap-2">
<label htmlFor="cvc" className="text-start text-sm font-medium">
CVC
</label>
<Input id="cvc" placeholder="123" className="w-full" />
</div>
</div>
</div>
)
}
const CompleteComponent = () => {
return <h3 className="py-4 text-lg font-medium">Stepper complete 🔥</h3>
}
export default App

View File

@@ -0,0 +1,181 @@
import type { CARRERAS } from '@/features/planes/new/catalogs'
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
FACULTADES,
NIVELES,
TIPOS_CICLO,
} from '@/features/planes/new/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
carrerasFiltradas,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
carrerasFiltradas: typeof CARRERAS
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026"
value={wizard.datosBasicos.nombrePlan}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultadId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: value,
carreraId: '',
},
}))
}
>
<SelectTrigger
id="facultad"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona facultad…" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carreraId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, carreraId: value },
}))
}
disabled={!wizard.datosBasicos.facultadId}
>
<SelectTrigger
id="carrera"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona carrera…" />
</SelectTrigger>
<SelectContent>
{carrerasFiltradas.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label>
<Select
value={wizard.datosBasicos.nivel}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nivel: value },
}))
}
>
<SelectTrigger
id="nivel"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona nivel…" />
</SelectTrigger>
<SelectContent>
{NIVELES.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select
value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipoCiclo: value as any,
},
}))
}
>
<SelectTrigger
id="tipoCiclo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_CICLO.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label>
<Input
id="numCiclos"
type="number"
min={1}
value={wizard.datosBasicos.numCiclos}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
numCiclos: Number(e.target.value || 1),
},
}))
}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,323 @@
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
CARRERAS,
FACULTADES,
PLANES_EXISTENTES,
} from '@/features/planes/new/catalogs'
export function PasoDetallesPanel({
wizard,
onChange,
onGenerarIA,
isLoading,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
onGenerarIA: () => void
isLoading: boolean
}) {
if (wizard.modoCreacion === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Creación manual</CardTitle>
<CardDescription>
Se creará un plan en blanco con estructura mínima.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.modoCreacion === 'IA') {
return (
<div className="grid gap-4">
<div>
<Label htmlFor="desc">Descripción del enfoque</Label>
<textarea
id="desc"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Describe el enfoque del programa…"
value={wizard.iaConfig?.descripcionEnfoque || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
descripcionEnfoque: e.target.value,
},
}))
}
/>
</div>
<div>
<Label htmlFor="poblacion">Población objetivo</Label>
<Input
id="poblacion"
placeholder="Ej. Egresados de bachillerato con perfil STEM"
value={wizard.iaConfig?.poblacionObjetivo || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
poblacionObjetivo: e.target.value,
},
}))
}
/>
</div>
<div>
<Label htmlFor="notas">Notas adicionales</Label>
<textarea
id="notas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Lineamientos institucionales, restricciones, etc."
value={wizard.iaConfig?.notasAdicionales || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
notasAdicionales: e.target.value,
},
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante.
</div>
<Button onClick={onGenerarIA} disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button>
</div>
{wizard.resumen.previewPlan && (
<Card>
<CardHeader>
<CardTitle>Preview IA</CardTitle>
<CardDescription>
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc pl-5 text-sm">
{wizard.resumen.previewPlan.secciones?.map((s) => (
<li key={s.id}>
<span className="text-foreground font-medium">
{s.titulo}:
</span>{' '}
{s.resumen}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)
}
if (
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'INTERNO'
) {
return (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<Label htmlFor="clonFacultad">Facultad</Label>
<select
id="clonFacultad"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
value={wizard.datosBasicos.facultadId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: e.target.value,
},
}))
}
>
<option value="">Todas</option>
{FACULTADES.map((f) => (
<option key={f.id} value={f.id}>
{f.nombre}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="clonCarrera">Carrera</Label>
<select
id="clonCarrera"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
value={wizard.datosBasicos.carreraId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
carreraId: e.target.value,
},
}))
}
>
<option value="">Todas</option>
{CARRERAS.map((c) => (
<option key={c.id} value={c.id}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="buscarPlan">Buscar</Label>
<Input
id="buscarPlan"
placeholder="Nombre del plan…"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value.toLowerCase()
void term
}}
/>
</div>
</div>
<div className="grid gap-3">
{PLANES_EXISTENTES.filter(
(p) =>
(!wizard.datosBasicos.facultadId ||
p.facultadId === wizard.datosBasicos.facultadId) &&
(!wizard.datosBasicos.carreraId ||
p.carreraId === wizard.datosBasicos.carreraId),
).map((p) => (
<Card
key={p.id}
className={
p.id === wizard.clonInterno?.planOrigenId
? 'ring-ring ring-2'
: ''
}
onClick={() =>
onChange((w) => ({ ...w, clonInterno: { planOrigenId: p.id } }))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{p.nombre}</span>
<span className="text-muted-foreground text-sm">
{p.estado} · {p.anio}
</span>
</CardTitle>
<CardDescription>ID: {p.id}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)
}
if (
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'TRADICIONAL'
) {
return (
<div className="grid gap-4">
<div>
<Label htmlFor="word">Word del plan (obligatorio)</Label>
<input
id="word"
type="file"
accept=".doc,.docx"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
}
/>
</div>
<div>
<Label htmlFor="mapa">Excel del mapa curricular</Label>
<input
id="mapa"
type="file"
accept=".xls,.xlsx"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
}
/>
</div>
<div>
<Label htmlFor="asignaturas">Excel/listado de asignaturas</Label>
<input
id="asignaturas"
type="file"
accept=".xls,.xlsx,.csv"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
}
/>
</div>
<div className="text-muted-foreground text-sm">
Sube al menos Word y uno de los Excel para continuar.
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Selecciona un modo</CardTitle>
<CardDescription>
Elige una opción en el paso anterior para continuar.
</CardDescription>
</CardHeader>
</Card>
)
}

View File

@@ -0,0 +1,122 @@
import * as Icons from 'lucide-react'
import type {
NewPlanWizardState,
ModoCreacion,
SubModoClonado,
} from '@/features/planes/new/types'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export function PasoModoCardGroup({
wizard,
onChange,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
}) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
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,
}))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Pencil className="text-primary h-5 w-5" /> Manual
</CardTitle>
<CardDescription>Plan vacío con estructura mínima.</CardDescription>
</CardHeader>
</Card>
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w) => ({
...w,
modoCreacion: 'IA',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Sparkles className="text-primary h-5 w-5" /> Con IA
</CardTitle>
<CardDescription>
Borrador completo a partir de datos base.
</CardDescription>
</CardHeader>
</Card>
<Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
role="button"
tabIndex={0}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Copy className="text-primary h-5 w-5" /> Clonado
</CardTitle>
<CardDescription>Desde un plan existente o archivos.</CardDescription>
</CardHeader>
{wizard.modoCreacion === 'CLONADO' && (
<CardContent 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 flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSubSelected('INTERNO')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground'
} `}
>
<Icons.Database className="mb-1 h-6 w-6" />
<span className="text-sm font-medium">Del sistema</span>
</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 flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSubSelected('TRADICIONAL')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground'
} `}
>
<Icons.Upload className="mb-1 h-6 w-6" />
<span className="text-sm font-medium">Desde archivos</span>
</div>
</CardContent>
)}
</Card>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import type { NewPlanWizardState } from '@/features/planes/new/types'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
const modo = wizard.modoCreacion
const sub = wizard.subModoClonado
return (
<Card>
<CardHeader>
<CardTitle>Resumen</CardTitle>
<CardDescription>
Verifica la información antes de crear.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<div>
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombrePlan || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Facultad/Carrera: </span>
<span className="font-medium">
{wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carreraId || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Nivel: </span>
<span className="font-medium">
{wizard.datosBasicos.nivel || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Ciclos: </span>
<span className="font-medium">
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
</span>
</div>
<div className="mt-2">
<span className="text-muted-foreground">Modo: </span>
<span className="font-medium">
{modo === 'MANUAL' && 'Manual'}
{modo === 'IA' && 'Generado con IA'}
{modo === 'CLONADO' &&
sub === 'INTERNO' &&
'Clonado desde plan del sistema'}
{modo === 'CLONADO' &&
sub === 'TRADICIONAL' &&
'Importado desde documentos tradicionales'}
</span>
</div>
{wizard.resumen.previewPlan && (
<div className="bg-muted mt-2 rounded-md p-3">
<div className="font-medium">Preview IA</div>
<div className="text-muted-foreground">
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export function StepWithTooltip({
title,
desc,
}: {
title: string
desc: string
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<TooltipProvider delayDuration={0}>
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger asChild>
<span
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
onClick={(e) => {
e.stopPropagation()
setIsOpen((prev) => !prev)
}}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[200px] text-xs">
<p>{desc}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,47 @@
import { Button } from '@/components/ui/button'
export function WizardControls({
errorMessage,
onPrev,
onNext,
onCreate,
disablePrev,
disableNext,
disableCreate,
isLastStep,
}: {
errorMessage?: string | null
onPrev: () => void
onNext: () => void
onCreate: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
}) {
return (
<div className="flex items-center justify-between">
<div className="flex-1">
{errorMessage && (
<span className="text-destructive text-sm font-medium">
{errorMessage}
</span>
)}
</div>
<div className="flex gap-4">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
{isLastStep ? (
<Button onClick={onCreate} disabled={disableCreate}>
Crear plan
</Button>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
</Button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import * as Icons from 'lucide-react'
import { StepWithTooltip } from './StepWithTooltip'
import { CircularProgress } from '@/components/CircularProgress'
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
export function WizardHeader({
currentIndex,
totalSteps,
currentTitle,
currentDescription,
nextTitle,
onClose,
Wizard,
}: {
currentIndex: number
totalSteps: number
currentTitle: string
currentDescription: string
nextTitle?: string
onClose: () => void
Wizard: any
}) {
return (
<div className="z-10 flex-none border-b bg-white">
<div className="flex items-center justify-between p-6 pb-4">
<DialogHeader className="p-0">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<button
onClick={onClose}
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
>
<Icons.X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</button>
</div>
<div className="px-6 pb-6">
<div className="block sm:hidden">
<div className="flex items-center gap-5">
<CircularProgress current={currentIndex} total={totalSteps} />
<div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip
title={currentTitle}
desc={currentDescription}
/>
</h2>
{nextTitle ? (
<p className="text-sm text-slate-400">Siguiente: {nextTitle}</p>
) : (
<p className="text-sm font-medium text-green-500">
¡Último paso!
</p>
)}
</div>
</div>
</div>
<div className="hidden sm:block">
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{Wizard.steps.map((step: any) => (
<Wizard.Stepper.Step
key={step.id}
of={step.id}
className="whitespace-nowrap"
>
<Wizard.Stepper.Title>
<StepWithTooltip title={step.title} desc={step.description} />
</Wizard.Stepper.Title>
</Wizard.Stepper.Step>
))}
</Wizard.Stepper.Navigation>
</div>
</div>
</div>
)
}

View File

@@ -57,15 +57,17 @@ const defineStepper = <const Steps extends Array<Stepperize.Step>>(
className,
...props
}) => {
// Avoid leaking non-DOM props like `initialStep` onto the div
const { initialStep, initialMetadata, ...restProps } = props as {
initialStep?: any
initialMetadata?: any
} & Record<string, unknown>
return (
<StepperContext.Provider
value={{ variant, labelOrientation, tracking }}
>
<Scoped
initialStep={props.initialStep}
initialMetadata={props.initialMetadata}
>
<StepperContainer className={className} {...props}>
<Scoped initialStep={initialStep} initialMetadata={initialMetadata}>
<StepperContainer className={className} {...(restProps as any)}>
{children}
</StepperContainer>
</Scoped>

View File

@@ -0,0 +1,127 @@
import { useNavigate } from '@tanstack/react-router'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import { Dialog, DialogContent } from '@/components/ui/dialog'
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',
},
)
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
} = useNuevaAsignaturaWizard(planId)
const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
}
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' ? (
<VistaSinPermisos onClose={handleClose} />
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => (
<>
<WizardHeader
title="Nueva Asignatura"
Wizard={Wizard}
methods={{ ...methods, onClose: handleClose }}
/>
<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>
<PasoMetodoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoConfiguracionPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<WizardControls
Wizard={Wizard}
methods={methods}
wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/>
</>
)}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,56 @@
import type { TipoAsignatura } from "./types";
export 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" },
];
export const TIPOS_MATERIA: Array<{ value: TipoAsignatura; label: string }> = [
{ value: "OBLIGATORIA", label: "Obligatoria" },
{ value: "OPTATIVA", label: "Optativa" },
{ value: "TRONCAL", label: "Troncal / Eje común" },
{ value: "OTRO", label: "Otro" },
];
export const FACULTADES = [
{ id: "ing", nombre: "Facultad de Ingeniería" },
{ id: "med", nombre: "Facultad de 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 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" },
];
export 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",
},
];
export 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" },
];

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1,
planId,
modoCreacion: null,
datosBasicos: {
nombre: "",
clave: "",
tipo: "OBLIGATORIA",
creditos: 0,
horasSemana: 0,
estructuraId: "",
},
clonInterno: {},
clonTradicional: {
archivoWordAsignaturaId: null,
archivosAdicionalesIds: [],
},
iaConfig: {
descripcionEnfoque: "",
notasAdicionales: "",
archivosExistentesIds: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
});
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?.asignaturaOrigenId;
}
if (wizard.subModoClonado === "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: false,
resumen: {
previewAsignatura: {
nombre: w.datosBasicos.nombre,
objetivo:
"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();
};
return {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
};
}

View File

@@ -0,0 +1,45 @@
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;
};
export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4;
planId: string;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: string;
};
clonInterno?: {
facultadId?: string;
carreraId?: string;
planOrigenId?: string;
asignaturaOrigenId?: string | null;
};
clonTradicional?: {
archivoWordAsignaturaId: string | null;
archivosAdicionalesIds: Array<string>;
};
iaConfig?: {
descripcionEnfoque: string;
notasAdicionales: string;
archivosExistentesIds: Array<string>;
};
resumen: {
previewAsignatura?: AsignaturaPreview;
};
isLoading: boolean;
errorMessage: string | null;
};

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,
archivoAsignaturasExcelId: 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.archivoAsignaturasExcelId;
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,
numAsignaturasAprox: 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;
numAsignaturasAprox?: 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;
archivoAsignaturasExcelId: string | null;
};
iaConfig?: {
descripcionEnfoque: string;
poblacionObjetivo: string;
notasAdicionales: string;
archivosReferencia: Array<string>;
};
resumen: { previewPlan?: PlanPreview };
isLoading: boolean;
errorMessage: string | null;
};

View File

@@ -9,29 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as Stepper2RouteImport } from './routes/stepper2'
import { Route as StepperRouteImport } from './routes/stepper'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
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 PlanesPlanIdIndexRouteImport } from './routes/planes/$planId/index'
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as AsignaturasListaNuevaRouteImport } from './routes/asignaturas/_lista/nueva'
import { Route as PlanesPlanIdAsignaturasListaRouteRouteImport } from './routes/planes/$planId/asignaturas/_lista/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasListaNuevaRouteImport } from './routes/planes/$planId/asignaturas/_lista/nueva'
const Stepper2Route = Stepper2RouteImport.update({
id: '/stepper2',
path: '/stepper2',
getParentRoute: () => rootRouteImport,
} as any)
const StepperRoute = StepperRouteImport.update({
id: '/stepper',
path: '/stepper',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -57,75 +45,71 @@ const PlanesListaRouteRoute = PlanesListaRouteRouteImport.update({
path: '/planes',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdRouteRoute = PlanesPlanIdRouteRouteImport.update({
id: '/planes/$planId',
path: '/planes/$planId',
const PlanesPlanIdIndexRoute = PlanesPlanIdIndexRouteImport.update({
id: '/planes/$planId/',
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({
const PlanesPlanIdAsignaturasListaRouteRoute =
PlanesPlanIdAsignaturasListaRouteRouteImport.update({
id: '/planes/$planId/asignaturas/_lista',
path: '/planes/$planId/asignaturas',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasListaNuevaRoute =
PlanesPlanIdAsignaturasListaNuevaRouteImport.update({
id: '/nueva',
path: '/nueva',
getParentRoute: () => AsignaturasListaRouteRoute,
} as any)
getParentRoute: () => PlanesPlanIdAsignaturasListaRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/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
'/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/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
'/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/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
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/$planId/asignaturas/_lista/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -133,74 +117,52 @@ export interface FileRouteTypes {
| '/'
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/asignaturas/$asignaturaId'
| '/asignaturas'
| '/planes/$planId'
| '/planes'
| '/demo/tanstack-query'
| '/asignaturas/nueva'
| '/planes/nuevo'
| '/planes/$planId'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/asignaturas/nueva'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/asignaturas/$asignaturaId'
| '/asignaturas'
| '/planes/$planId'
| '/planes'
| '/demo/tanstack-query'
| '/asignaturas/nueva'
| '/planes/nuevo'
| '/planes/$planId'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/asignaturas/nueva'
id:
| '__root__'
| '/'
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/asignaturas/$asignaturaId'
| '/asignaturas/_lista'
| '/planes/$planId'
| '/planes/_lista'
| '/demo/tanstack-query'
| '/asignaturas/_lista/nueva'
| '/planes/_lista/nuevo'
| '/planes/$planId/'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas/_lista'
| '/planes/$planId/asignaturas/_lista/nueva'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
StepperRoute: typeof StepperRoute
Stepper2Route: typeof Stepper2Route
AsignaturasAsignaturaIdRouteRoute: typeof AsignaturasAsignaturaIdRouteRoute
AsignaturasListaRouteRoute: typeof AsignaturasListaRouteRouteWithChildren
PlanesPlanIdRouteRoute: typeof PlanesPlanIdRouteRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesPlanIdIndexRoute: typeof PlanesPlanIdIndexRoute
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
PlanesPlanIdAsignaturasListaRouteRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/stepper2': {
id: '/stepper2'
path: '/stepper2'
fullPath: '/stepper2'
preLoaderRoute: typeof Stepper2RouteImport
parentRoute: typeof rootRouteImport
}
'/stepper': {
id: '/stepper'
path: '/stepper'
fullPath: '/stepper'
preLoaderRoute: typeof StepperRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
@@ -236,25 +198,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesListaRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId': {
id: '/planes/$planId'
'/planes/$planId/': {
id: '/planes/$planId/'
path: '/planes/$planId'
fullPath: '/planes/$planId'
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
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista/nuevo': {
@@ -264,29 +212,30 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesListaNuevoRouteImport
parentRoute: typeof PlanesListaRouteRoute
}
'/asignaturas/_lista/nueva': {
id: '/asignaturas/_lista/nueva'
'/planes/$planId/asignaturas/_lista': {
id: '/planes/$planId/asignaturas/_lista'
path: '/planes/$planId/asignaturas'
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/_lista/nueva': {
id: '/planes/$planId/asignaturas/_lista/nueva'
path: '/nueva'
fullPath: '/asignaturas/nueva'
preLoaderRoute: typeof AsignaturasListaNuevaRouteImport
parentRoute: typeof AsignaturasListaRouteRoute
fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaNuevaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasListaRouteRoute
}
}
}
interface AsignaturasListaRouteRouteChildren {
AsignaturasListaNuevaRoute: typeof AsignaturasListaNuevaRoute
}
const AsignaturasListaRouteRouteChildren: AsignaturasListaRouteRouteChildren = {
AsignaturasListaNuevaRoute: AsignaturasListaNuevaRoute,
}
const AsignaturasListaRouteRouteWithChildren =
AsignaturasListaRouteRoute._addFileChildren(
AsignaturasListaRouteRouteChildren,
)
interface PlanesListaRouteRouteChildren {
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
}
@@ -298,17 +247,32 @@ const PlanesListaRouteRouteChildren: PlanesListaRouteRouteChildren = {
const PlanesListaRouteRouteWithChildren =
PlanesListaRouteRoute._addFileChildren(PlanesListaRouteRouteChildren)
interface PlanesPlanIdAsignaturasListaRouteRouteChildren {
PlanesPlanIdAsignaturasListaNuevaRoute: typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
const PlanesPlanIdAsignaturasListaRouteRouteChildren: PlanesPlanIdAsignaturasListaRouteRouteChildren =
{
PlanesPlanIdAsignaturasListaNuevaRoute:
PlanesPlanIdAsignaturasListaNuevaRoute,
}
const PlanesPlanIdAsignaturasListaRouteRouteWithChildren =
PlanesPlanIdAsignaturasListaRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasListaRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
StepperRoute: StepperRoute,
Stepper2Route: Stepper2Route,
AsignaturasAsignaturaIdRouteRoute: AsignaturasAsignaturaIdRouteRoute,
AsignaturasListaRouteRoute: AsignaturasListaRouteRouteWithChildren,
PlanesPlanIdRouteRoute: PlanesPlanIdRouteRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesPlanIdIndexRoute: PlanesPlanIdIndexRoute,
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
PlanesPlanIdAsignaturasListaRouteRoute:
PlanesPlanIdAsignaturasListaRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/asignaturas/$asignaturaId')({
export const Route = createFileRoute('/planes/$planId/asignaturas/$asignaturaId')({
component: RouteComponent,
})

View File

@@ -0,0 +1,14 @@
import { createFileRoute } from '@tanstack/react-router'
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/new/NuevaAsignaturaModalContainer'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/_lista/nueva',
)({
component: NuevaAsignaturaModal,
})
function NuevaAsignaturaModal() {
const { planId } = Route.useParams()
return <NuevaAsignaturaModalContainer planId={planId} />
}

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/asignaturas/_lista')({
export const Route = createFileRoute('/planes/$planId/asignaturas/_lista')({
component: RouteComponent,
})
@@ -8,6 +8,7 @@ function RouteComponent() {
return (
<main className="bg-background min-h-screen w-full">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
<h1 className="text-foreground text-2xl font-semibold">Asignaturas</h1>
<Outlet />
</div>
</main>

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId')({
export const Route = createFileRoute('/planes/$planId/')({
component: RouteComponent,
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { defineStepper } from '@/components/stepper'
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/stepper')({
component: MyFirstStepper,
})
const stepperInstance = defineStepper(
{ id: 'step-1', title: 'Step 1' },
{ id: 'step-2', title: 'Step 2' },
{ id: 'step-3', title: 'Step 3' },
)
export function MyFirstStepper() {
return (
<stepperInstance.Stepper.Provider className="space-y-4">
{({ methods }) => (
<>
<stepperInstance.Stepper.Navigation>
{methods.all.map((step) => (
<stepperInstance.Stepper.Step
of={step.id}
onClick={() => methods.goTo(step.id)}
>
<stepperInstance.Stepper.Title>
{step.title}
</stepperInstance.Stepper.Title>
</stepperInstance.Stepper.Step>
))}
</stepperInstance.Stepper.Navigation>
{methods.switch({
'step-1': (step) => <Content id={step.id} />,
'step-2': (step) => <Content id={step.id} />,
'step-3': (step) => <Content id={step.id} />,
})}
<stepperInstance.Stepper.Controls>
{!methods.isLast && (
<Button
type="button"
variant="secondary"
onClick={methods.prev}
disabled={methods.isFirst}
>
Previous
</Button>
)}
<Button onClick={methods.isLast ? methods.reset : methods.next}>
{methods.isLast ? 'Reset' : 'Next'}
</Button>
</stepperInstance.Stepper.Controls>
</>
)}
</stepperInstance.Stepper.Provider>
)
}
const Content = ({ id }: { id: string }) => {
return (
<stepperInstance.Stepper.Panel className="h-50 content-center rounded border bg-slate-50 p-8">
<p className="text-xl font-normal">Content for {id}</p>
</stepperInstance.Stepper.Panel>
)
}

View File

@@ -1,82 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { CircularProgress } from '@/components/CircularProgress'
import { defineStepper } from '@/components/stepper' // Tu wrapper
import { Button } from '@/components/ui/button'
export const Route = createFileRoute('/stepper2')({
component: MobileStepperView,
})
// 1. Definimos los pasos igual que siempre
const myStepper = defineStepper(
{ id: 'contact', title: 'Contact Details' },
{ id: 'shipping', title: 'Shipping Information' },
{ id: 'billing', title: 'Billing Address' },
{ id: 'review', title: 'Payment Review' },
)
export default function MobileStepperView() {
return (
// Usa el Provider del wrapper para tener el contexto
<myStepper.Stepper.Provider>
{({ methods }) => {
// Calculamos índices para el gráfico
const currentIndex =
methods.all.findIndex((s) => s.id === methods.current.id) + 1
const totalSteps = methods.all.length
const nextStep = methods.all[currentIndex] // El paso siguiente (si existe)
return (
<div className="flex h-full flex-col bg-white p-4">
{/* --- AQUÍ ESTÁ LA MAGIA (Tu UI Personalizada) --- */}
<div className="mb-6 flex items-center gap-4">
{/* El Gráfico Circular */}
<CircularProgress current={currentIndex} total={totalSteps} />
{/* Los Textos */}
<div className="flex flex-col">
<h2 className="text-lg font-bold text-slate-900">
{methods.current.title}
</h2>
{nextStep && (
<p className="text-sm text-slate-400">
Next: {nextStep.title}
</p>
)}
</div>
</div>
{/* ----------------------------------------------- */}
{/* El contenido de los pasos (Switch) */}
<div className="flex-1">
{methods.switch({
contact: () => <div>Formulario Contacto...</div>,
shipping: () => <div>Formulario Envío...</div>,
billing: () => <div>Formulario Facturación...</div>,
review: () => <div>Resumen...</div>,
})}
</div>
{/* Controles de Navegación (Footer) */}
<div className="mt-4 flex justify-between">
<Button
variant="ghost"
onClick={methods.prev}
disabled={methods.isFirst}
>
Back
</Button>
<Button
className="bg-red-500 text-white hover:bg-red-600"
onClick={methods.next}
>
{methods.isLast ? 'Finish' : 'Next'}
</Button>
</div>
</div>
)
}}
</myStepper.Stepper.Provider>
)
}