This commit is contained in:
2025-12-31 13:34:09 -06:00
parent 8d20fd4492
commit 09e9e03767
4 changed files with 324 additions and 138 deletions

View File

@@ -172,7 +172,7 @@ function NuevoPlanModal() {
navigate({ to: '/planes', resetScroll: false })
}
// Derivados
// --- LÓGICA DE VALIDACIÓN Y DATA ---
const carrerasFiltradas = useMemo(() => {
const fac = wizard.datosBasicos.facultadId
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS
@@ -202,7 +202,6 @@ function NuevoPlanModal() {
if (wizard.subModoClonado === 'TRADICIONAL') {
const t = wizard.clonTradicional
if (!t) return false
// Reglas mínimas: Word + al menos un Excel
const tieneWord = !!t.archivoWordPlanId
const tieneAlMenosUnExcel =
!!t.archivoMapaExcelId || !!t.archivoMateriasExcelId
@@ -236,7 +235,6 @@ function NuevoPlanModal() {
const crearPlan = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
await new Promise((r) => setTimeout(r, 900))
// Elegimos un id ficticio distinto según modo
const nuevoId = (() => {
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
@@ -245,157 +243,168 @@ function NuevoPlanModal() {
})()
navigate({ to: `/planes/${nuevoId}` })
}
// ------------------------------------------------
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="p-0 sm:max-w-[840px]">
<DialogHeader className="px-6 pt-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
{/* FIX LAYOUT:
1. h-[90vh]: Altura fija del 90% de la ventana.
2. flex-col gap-0 p-0: Quitamos espacios automáticos para control manual.
*/}
<DialogContent className="flex h-[90vh] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
{role !== 'JEFE_CARRERA' ? (
<div className="px-6 pb-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 variant="secondary" onClick={handleClose}>
Volver
</Button>
</CardContent>
</Card>
</div>
// --- VISTA SIN PERMISOS ---
<>
<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 variant="secondary" onClick={handleClose}>
Volver
</Button>
</CardContent>
</Card>
</div>
</>
) : (
<div className="px-6 pb-6">
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex flex-col gap-6"
>
{({ methods }) => (
<>
{/* Header + navegación */}
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border p-4">
<Wizard.Stepper.Step of={Wizard.steps[0].id}>
<Wizard.Stepper.Title>1. Método</Wizard.Stepper.Title>
<Wizard.Stepper.Description>
Selecciona cómo crearás el plan
</Wizard.Stepper.Description>
</Wizard.Stepper.Step>
<Wizard.Stepper.Step of={Wizard.steps[1].id}>
<Wizard.Stepper.Title>
2. Datos básicos
</Wizard.Stepper.Title>
<Wizard.Stepper.Description>
Nombre, carrera, nivel y ciclos
</Wizard.Stepper.Description>
</Wizard.Stepper.Step>
<Wizard.Stepper.Step of={Wizard.steps[2].id}>
<Wizard.Stepper.Title>3. Detalles</Wizard.Stepper.Title>
<Wizard.Stepper.Description>
IA, clonado o archivos
</Wizard.Stepper.Description>
</Wizard.Stepper.Step>
<Wizard.Stepper.Step of={Wizard.steps[3].id}>
<Wizard.Stepper.Title>4. Resumen</Wizard.Stepper.Title>
<Wizard.Stepper.Description>
Confirma y crea el plan
</Wizard.Stepper.Description>
</Wizard.Stepper.Step>
</Wizard.Stepper.Navigation>
// --- VISTA WIZARD ---
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => (
<>
{/* 1. HEADER (FIJO - Top Bun)
flex-none: Asegura que no se aplaste ni haga scroll.
*/}
<div className="z-10 flex-none border-b bg-white">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
{/* Info de paso actual */}
<div className="flex items-center justify-end px-1">
<span className="text-muted-foreground text-sm">
Paso {Wizard.utils.getIndex(methods.current.id) + 1} de{' '}
{Wizard.steps.length}
</span>
{/* Navegación dentro del bloque fijo */}
<div className="px-6 pb-6">
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{/* Nota: En móvil esto se verá apretado. Considera overflow-x-auto si tienes muchos pasos */}
<div className="flex items-center justify-between gap-2 overflow-x-auto">
{Wizard.steps.map((step) => (
<Wizard.Stepper.Step
key={step.id}
of={step.id}
className="whitespace-nowrap"
>
<Wizard.Stepper.Title>
{step.title}
</Wizard.Stepper.Title>
</Wizard.Stepper.Step>
))}
</div>
</Wizard.Stepper.Navigation>
</div>
</div>
{/* Panel activo (solo uno visible) */}
<div className="grid gap-6 md:grid-cols-2">
<div className="md:col-span-2">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoModo wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicos
wizard={wizard}
onChange={setWizard}
carrerasFiltradas={carrerasFiltradas}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetalles
wizard={wizard}
onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumen wizard={wizard} />
</Wizard.Stepper.Panel>
{/* 2. CONTENIDO (SCROLLEABLE - Meat)
flex-1: Ocupa todo el espacio restante.
overflow-y-auto: Scrollea solo esta parte.
*/}
<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 */}
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoModo wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicos
wizard={wizard}
onChange={setWizard}
carrerasFiltradas={carrerasFiltradas}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetalles
wizard={wizard}
onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumen wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
{/* 3. CONTROLES (FIJO - Bottom Bun)
flex-none: Siempre visible abajo.
*/}
<div className="flex-none border-t bg-white p-6">
<Wizard.Stepper.Controls 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>
{/* Controles */}
<Wizard.Stepper.Controls>
{wizard.errorMessage && (
<span className="text-destructive mr-auto text-sm">
{wizard.errorMessage}
</span>
)}
<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 ? (
<div className="flex gap-4">
<Button
onClick={() => methods.next()}
variant="secondary"
onClick={() => methods.prev()}
disabled={
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)
Wizard.utils.getIndex(methods.current.id) === 0 ||
wizard.isLoading
}
>
Siguiente
Anterior
</Button>
) : (
<Button onClick={crearPlan} disabled={wizard.isLoading}>
Crear plan
</Button>
)}
{Wizard.utils.getIndex(methods.current.id) <
Wizard.steps.length - 1 ? (
<Button
onClick={() => methods.next()}
disabled={
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)
}
>
Siguiente
</Button>
) : (
<Button onClick={crearPlan} disabled={wizard.isLoading}>
Crear plan
</Button>
)}
</div>
</Wizard.Stepper.Controls>
</>
)}
</Wizard.Stepper.Provider>
</div>
</div>
</>
)}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
@@ -506,7 +515,7 @@ function PasoBasicos({
carrerasFiltradas: typeof CARRERAS
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-20 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input

82
src/routes/stepper2.tsx Normal file
View File

@@ -0,0 +1,82 @@
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>
)
}