Refactorización de wizards para consistencia, reusabilidad y mantenibilidad

This commit is contained in:
2026-02-03 13:01:51 -06:00
parent 12c572a442
commit f046bdcc04
6 changed files with 366 additions and 247 deletions

View File

@@ -23,43 +23,41 @@ export function WizardControls({
const isLast = idx >= Wizard.steps.length - 1 const isLast = idx >= Wizard.steps.length - 1
return ( return (
<div className="flex-none border-t bg-white p-6"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex-1">
<div className="flex-1"> {wizard.errorMessage && (
{wizard.errorMessage && ( <span className="text-destructive text-sm font-medium">
<span className="text-destructive text-sm font-medium"> {wizard.errorMessage}
{wizard.errorMessage} </span>
</span> )}
)} </div>
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
</Button>
{!isLast ? (
<Button <Button
variant="secondary" onClick={() => methods.next()}
onClick={() => methods.prev()} disabled={
disabled={idx === 0 || wizard.isLoading} wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeConfig)
}
> >
Anterior Siguiente
</Button> </Button>
) : (
{!isLast ? ( <Button onClick={onCreate} disabled={wizard.isLoading}>
<Button {wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
onClick={() => methods.next()} </Button>
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>
</div> </div>
) )

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-50 text-xs">
<p>{desc}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,52 @@
import * as Icons from 'lucide-react'
import { CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export function WizardLayout({
title,
onClose,
headerSlot,
footerSlot,
children,
}: {
title: string
onClose: () => void
headerSlot?: React.ReactNode
footerSlot?: React.ReactNode
children: React.ReactNode
}) {
return (
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<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()
}}
>
<div className="z-10 flex-none border-b bg-white">
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
<CardTitle>{title}</CardTitle>
<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>
</CardHeader>
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
</div>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
{children}
</div>
{footerSlot ? (
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,59 @@
import { CircularProgress } from '@/components/CircularProgress'
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
export function WizardResponsiveHeader({
wizard,
methods,
}: {
wizard: any
methods: any
}) {
const idx = wizard.utils.getIndex(methods.current.id)
const totalSteps = wizard.steps.length
const currentIndex = idx + 1
const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex]
return (
<>
<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>
{hasNextStep && 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>
</>
)
}

View File

@@ -1,4 +1,5 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard' import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
@@ -6,13 +7,20 @@ import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel' import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup' import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls' import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import { Dialog, DialogContent } from '@/components/ui/dialog' import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper( const Wizard = defineStepper(
{ {
@@ -55,85 +63,90 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false }) navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
} }
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nueva Asignatura" onClose={handleClose}>
<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={handleClose}>
Volver
</Button>
</CardContent>
</Card>
</WizardLayout>
)
}
return ( return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}> <Wizard.Stepper.Provider
<DialogContent initialStep={Wizard.utils.getFirst().id}
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl" className="flex h-full flex-col"
onInteractOutside={(e) => e.preventDefault()} >
> {({ methods }) => {
{role !== 'JEFE_CARRERA' ? ( const idx = Wizard.utils.getIndex(methods.current.id)
<VistaSinPermisos onClose={handleClose} />
) : ( return (
<Wizard.Stepper.Provider <WizardLayout
initialStep={Wizard.utils.getFirst().id} title="Nueva Asignatura"
className="flex h-full flex-col" onClose={handleClose}
headerSlot={
<WizardResponsiveHeader wizard={Wizard} methods={methods} />
}
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
Wizard={Wizard}
methods={methods}
wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/>
</Wizard.Stepper.Controls>
}
> >
{({ methods }) => { <div className="mx-auto max-w-3xl">
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1 {idx === 0 && (
const totalSteps = Wizard.steps.length <Wizard.Stepper.Panel>
const nextStep = Wizard.steps[currentIndex] ?? { <PasoMetodoCardGroup wizard={wizard} onChange={setWizard} />
title: '', </Wizard.Stepper.Panel>
description: '', )}
}
return ( {idx === 1 && (
<> <Wizard.Stepper.Panel>
<WizardHeader <PasoBasicosForm wizard={wizard} onChange={setWizard} />
title="Nueva Asignatura" </Wizard.Stepper.Panel>
Wizard={Wizard} )}
methods={{ ...methods, onClose: handleClose }}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6"> {idx === 2 && (
<div className="mx-auto max-w-3xl"> <Wizard.Stepper.Panel>
{Wizard.utils.getIndex(methods.current.id) === 0 && ( <PasoConfiguracionPanel
<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} wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo} onChange={setWizard}
canContinueDesdeBasicos={canContinueDesdeBasicos} onGenerarIA={simularGeneracionIA}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/> />
</> </Wizard.Stepper.Panel>
) )}
}}
</Wizard.Stepper.Provider> {idx === 3 && (
)} <Wizard.Stepper.Panel>
</DialogContent> <PasoResumenCard wizard={wizard} />
</Dialog> </Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
) )
} }

View File

@@ -10,7 +10,6 @@ import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup' import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
import { WizardControls } from '@/components/planes/wizard/WizardControls' import { WizardControls } from '@/components/planes/wizard/WizardControls'
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import { import {
Card, Card,
@@ -19,16 +18,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { import { WizardLayout } from '@/components/wizard/WizardLayout'
Dialog, import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
// import { useGeneratePlanAI } from '@/data/hooks/usePlans' // import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol // Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper( const Wizard = defineStepper(
{ {
@@ -64,136 +59,97 @@ export default function NuevoPlanModalContainer() {
// Crear plan: ahora la lógica vive en WizardControls // Crear plan: ahora la lógica vive en WizardControls
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nuevo plan de estudios" onClose={handleClose}>
<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>
</WizardLayout>
)
}
return ( return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}> <Wizard.Stepper.Provider
<DialogContent initialStep={Wizard.utils.getFirst().id}
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl" className="flex h-full flex-col"
onInteractOutside={(e) => { >
e.preventDefault() {({ methods }) => {
}} const idx = Wizard.utils.getIndex(methods.current.id)
>
{role !== 'JEFE_CARRERA' ? ( return (
<> <WizardLayout
<DialogHeader className="flex-none border-b p-6"> title="Nuevo plan de estudios"
<DialogTitle>Nuevo plan de estudios</DialogTitle> onClose={handleClose}
</DialogHeader> headerSlot={
<div className="flex-1 p-6"> <WizardResponsiveHeader wizard={Wizard} methods={methods} />
<Card className="border-destructive/40"> }
<CardHeader> footerSlot={
<CardTitle className="flex items-center gap-2"> <Wizard.Stepper.Controls>
<Icons.ShieldAlert className="text-destructive h-5 w-5" /> <WizardControls
Sin permisos errorMessage={wizard.errorMessage}
</CardTitle> onPrev={() => methods.prev()}
<CardDescription> onNext={() => methods.next()}
No tienes permisos para crear planes de estudio. disablePrev={idx === 0 || wizard.isLoading}
</CardDescription> disableNext={
</CardHeader> wizard.isLoading ||
<CardContent className="flex justify-end"> (idx === 0 && !canContinueDesdeModo) ||
<button (idx === 1 && !canContinueDesdeBasicos) ||
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" (idx === 2 && !canContinueDesdeDetalles)
onClick={handleClose} }
> disableCreate={wizard.isLoading}
Volver isLastStep={idx >= Wizard.steps.length - 1}
</button> wizard={wizard}
</CardContent> setWizard={setWizard}
</Card> />
</div> </Wizard.Stepper.Controls>
</> }
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
> >
{({ methods }) => { <div className="mx-auto max-w-3xl">
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1 {idx === 0 && (
const totalSteps = Wizard.steps.length <Wizard.Stepper.Panel>
const nextStep = Wizard.steps[currentIndex] ?? { <PasoModoCardGroup wizard={wizard} onChange={setWizard} />
title: '', </Wizard.Stepper.Panel>
description: '', )}
} {idx === 1 && (
<Wizard.Stepper.Panel>
return ( <PasoBasicosForm wizard={wizard} onChange={setWizard} />
<> </Wizard.Stepper.Panel>
<WizardHeader )}
currentIndex={currentIndex} {idx === 2 && (
totalSteps={totalSteps} <Wizard.Stepper.Panel>
currentTitle={methods.current.title} <PasoDetallesPanel
currentDescription={methods.current.description} wizard={wizard}
nextTitle={nextStep.title} onChange={setWizard}
onClose={handleClose} isLoading={wizard.isLoading}
Wizard={Wizard}
/> />
</Wizard.Stepper.Panel>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6"> )}
<div className="mx-auto max-w-3xl"> {idx === 3 && (
{Wizard.utils.getIndex(methods.current.id) === 0 && ( <Wizard.Stepper.Panel>
<Wizard.Stepper.Panel> <PasoResumenCard wizard={wizard} />
<PasoModoCardGroup </Wizard.Stepper.Panel>
wizard={wizard} )}
onChange={setWizard} </div>
/> </WizardLayout>
</Wizard.Stepper.Panel> )
)} }}
{Wizard.utils.getIndex(methods.current.id) === 1 && ( </Wizard.Stepper.Provider>
<Wizard.Stepper.Panel>
<PasoBasicosForm
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
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()}
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={wizard}
setWizard={setWizard}
/>
</Wizard.Stepper.Controls>
</div>
</>
)
}}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
) )
} }