Responsividad correcta para el wizard

This commit is contained in:
2025-12-31 14:32:55 -06:00
parent f535eea085
commit 6a2a4c0f05

View File

@@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { CircularProgress } from '@/components/CircularProgress'
import { defineStepper } from '@/components/stepper' import { defineStepper } from '@/components/stepper'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -19,6 +20,12 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export const Route = createFileRoute('/planes/_lista/nuevo')({ export const Route = createFileRoute('/planes/_lista/nuevo')({
component: NuevoPlanModal, component: NuevoPlanModal,
@@ -251,7 +258,12 @@ function NuevoPlanModal() {
1. h-[90vh]: Altura fija del 90% de la ventana. 1. h-[90vh]: Altura fija del 90% de la ventana.
2. flex-col gap-0 p-0: Quitamos espacios automáticos para control manual. 2. flex-col gap-0 p-0: Quitamos espacios automáticos para control manual.
*/} */}
<DialogContent className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"> <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' ? ( {role !== 'JEFE_CARRERA' ? (
// --- VISTA SIN PERMISOS --- // --- VISTA SIN PERMISOS ---
<> <>
@@ -283,126 +295,191 @@ function NuevoPlanModal() {
initialStep={Wizard.utils.getFirst().id} initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col" className="flex h-full flex-col"
> >
{({ methods }) => ( {({ methods }) => {
<> // --- LÓGICA PARA MÓVIL (Cálculos) ---
{/* 1. HEADER (FIJO - Top Bun) const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
flex-none: Asegura que no se aplaste ni haga scroll. const totalSteps = Wizard.steps.length
*/} const nextStep = Wizard.steps[currentIndex]
<div className="z-10 flex-none border-b bg-white"> return (
<DialogHeader className="p-6 pb-4"> <>
<DialogTitle>Nuevo plan de estudios</DialogTitle> {/* =========================================================
</DialogHeader> HEADER RESPONSIVO
========================================================= */}
<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">
{' '}
{/* Quitamos padding interno porque ya lo tiene el div padre */}
<DialogTitle /* className="hidden sm:block" */>
Nuevo plan de estudios
</DialogTitle>
</DialogHeader>
{/* Navegación dentro del bloque fijo */} {/* BOTÓN DE CERRAR (X) */}
<div className="px-6 pb-6"> <button
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2"> onClick={handleClose}
{/* Nota: En móvil esto se verá apretado. Considera overflow-x-auto si tienes muchos pasos */} 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>
{Wizard.steps.map((step) => ( <div className="px-6 pb-6">
<Wizard.Stepper.Step {/* OPCIÓN A: MÓVIL (< 640px)
key={step.id} - Usa block sm:hidden
of={step.id} - Muestra el CircularProgress y Texto Grande
className="whitespace-nowrap" */}
> <div className="block sm:hidden">
<Wizard.Stepper.Title> <div className="flex items-center gap-5">
{step.title} <CircularProgress
</Wizard.Stepper.Title> current={currentIndex}
</Wizard.Stepper.Step> total={totalSteps}
))} />
</Wizard.Stepper.Navigation> <div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900">
{/* Usamos el Helper del Tooltip aquí también */}
<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>
{/* OPCIÓN B: DESKTOP (>= 640px)
- Usa hidden sm:block
- Muestra la navegación original horizontal
*/}
<div className="hidden sm:block">
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{Wizard.steps.map((step) => (
<Wizard.Stepper.Step
key={step.id}
of={step.id}
className="whitespace-nowrap"
>
<Wizard.Stepper.Title>
{/* Envolvemos el título en nuestro componente Tooltip */}
<StepWithTooltip
title={step.title}
desc={step.description}
/>
</Wizard.Stepper.Title>
</Wizard.Stepper.Step>
))}
</Wizard.Stepper.Navigation>
</div>
</div>
</div> </div>
</div>
{/* 2. CONTENIDO (SCROLLEABLE - Meat) {/* 2. CONTENIDO (SCROLLEABLE - Meat)
flex-1: Ocupa todo el espacio restante. flex-1: Ocupa todo el espacio restante.
overflow-y-auto: Scrollea solo esta parte. overflow-y-auto: Scrollea solo esta parte.
*/} */}
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6"> <div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
{/* Aquí renderizamos el panel. Quitamos el 'grid' del padre para evitar conflictos de altura */} {/* Aquí renderizamos el panel. Quitamos el 'grid' del padre para evitar conflictos de altura */}
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && ( {Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoModo wizard={wizard} onChange={setWizard} /> <PasoModo wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{Wizard.utils.getIndex(methods.current.id) === 1 && ( {Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoBasicos <PasoBasicos
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
carrerasFiltradas={carrerasFiltradas} carrerasFiltradas={carrerasFiltradas}
/> />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{Wizard.utils.getIndex(methods.current.id) === 2 && ( {Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoDetalles <PasoDetalles
wizard={wizard} wizard={wizard}
onChange={setWizard} onChange={setWizard}
onGenerarIA={generarPreviewIA} onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading} isLoading={wizard.isLoading}
/> />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
{Wizard.utils.getIndex(methods.current.id) === 3 && ( {Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoResumen wizard={wizard} /> <PasoResumen wizard={wizard} />
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}
</div>
</div> </div>
</div>
{/* 3. CONTROLES (FIJO - Bottom Bun) {/* 3. CONTROLES (FIJO - Bottom Bun)
flex-none: Siempre visible abajo. flex-none: Siempre visible abajo.
*/} */}
<div className="flex-none border-t bg-white p-6"> <div className="flex-none border-t bg-white p-6">
<Wizard.Stepper.Controls className="flex items-center justify-between"> <Wizard.Stepper.Controls 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={
Wizard.utils.getIndex(methods.current.id) === 0 ||
wizard.isLoading
}
>
Anterior
</Button>
{Wizard.utils.getIndex(methods.current.id) <
Wizard.steps.length - 1 ? (
<Button <Button
onClick={() => methods.next()} variant="secondary"
onClick={() => methods.prev()}
disabled={ disabled={
wizard.isLoading || Wizard.utils.getIndex(methods.current.id) === 0 ||
(Wizard.utils.getIndex(methods.current.id) === 0 && wizard.isLoading
!canContinueDesdeModo) ||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
!canContinueDesdeBasicos) ||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
!canContinueDesdeDetalles)
} }
> >
Siguiente Anterior
</Button> </Button>
) : (
<Button onClick={crearPlan} disabled={wizard.isLoading}> {Wizard.utils.getIndex(methods.current.id) <
Crear plan Wizard.steps.length - 1 ? (
</Button> <Button
)} onClick={() => methods.next()}
</div> disabled={
</Wizard.Stepper.Controls> wizard.isLoading ||
</div> (Wizard.utils.getIndex(methods.current.id) ===
</> 0 &&
)} !canContinueDesdeModo) ||
(Wizard.utils.getIndex(methods.current.id) ===
1 &&
!canContinueDesdeBasicos) ||
(Wizard.utils.getIndex(methods.current.id) ===
2 &&
!canContinueDesdeDetalles)
}
>
Siguiente
</Button>
) : (
<Button
onClick={crearPlan}
disabled={wizard.isLoading}
>
Crear plan
</Button>
)}
</div>
</Wizard.Stepper.Controls>
</div>
</>
)
}}
</Wizard.Stepper.Provider> </Wizard.Stepper.Provider>
)} )}
</DialogContent> </DialogContent>
@@ -410,6 +487,33 @@ function NuevoPlanModal() {
) )
} }
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() // Evita que el clic dispare la navegación del stepper si está dentro de un botón
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>
)
}
// Paso 1: selección de modo // Paso 1: selección de modo
function PasoModo({ function PasoModo({
wizard, wizard,
@@ -477,7 +581,7 @@ function PasoModo({
<CardDescription>Desde un plan existente o archivos.</CardDescription> <CardDescription>Desde un plan existente o archivos.</CardDescription>
</CardHeader> </CardHeader>
{wizard.modoCreacion === 'CLONADO' && ( {wizard.modoCreacion === 'CLONADO' && (
<CardContent className="flex gap-3"> <CardContent className="flex flex-wrap gap-3">
<Button <Button
variant={isSubSelected('INTERNO') ? 'default' : 'secondary'} variant={isSubSelected('INTERNO') ? 'default' : 'secondary'}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
@@ -515,7 +619,7 @@ function PasoBasicos({
}) { }) {
return ( return (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label> <Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input <Input
id="nombrePlan" id="nombrePlan"
@@ -530,7 +634,7 @@ function PasoBasicos({
/> />
</div> </div>
<div> <div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label> <Label htmlFor="facultad">Facultad</Label>
<select <select
id="facultad" id="facultad"
@@ -556,7 +660,7 @@ function PasoBasicos({
</select> </select>
</div> </div>
<div> <div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label> <Label htmlFor="carrera">Carrera</Label>
<select <select
id="carrera" id="carrera"
@@ -578,7 +682,7 @@ function PasoBasicos({
</select> </select>
</div> </div>
<div> <div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label> <Label htmlFor="nivel">Nivel</Label>
<select <select
id="nivel" id="nivel"
@@ -600,7 +704,7 @@ function PasoBasicos({
</select> </select>
</div> </div>
<div> <div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label> <Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<select <select
id="tipoCiclo" id="tipoCiclo"
@@ -624,7 +728,7 @@ function PasoBasicos({
</select> </select>
</div> </div>
<div> <div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label> <Label htmlFor="numCiclos">Número de ciclos</Label>
<Input <Input
id="numCiclos" id="numCiclos"