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

@@ -0,0 +1,74 @@
import { cn } from '@/lib/utils'
interface CircularProgressProps {
current: number
total: number
className?: string
}
export function CircularProgress({
current,
total,
className,
}: CircularProgressProps) {
// Configuración interna del SVG (Coordenadas 100x100)
const center = 50
const strokeWidth = 8 // Grosor de la línea
const radius = 40 // Radio (dejamos margen para el borde)
const circumference = 2 * Math.PI * radius
// Cálculo del porcentaje inverso (para que se llene correctamente)
const percentage = (current / total) * 100
const strokeDashoffset = circumference - (percentage / 100) * circumference
return (
// CAMBIO CLAVE 1: 'size-24' (96px) da mucho más aire que 'size-16'
<div
className={cn(
'relative flex size-20 items-center justify-center',
className,
)}
>
{/* CAMBIO CLAVE 2: Contenedor de texto con inset-0 para centrado perfecto */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="mb-1 text-sm leading-none font-medium text-slate-500">
Paso
</span>
<span className="text-base leading-none font-bold text-slate-900">
{current}{' '}
<span className="text-base font-normal text-slate-400">
/ {total}
</span>
</span>
</div>
{/* SVG con viewBox para escalar automáticamente */}
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
{/* Círculo de Fondo (Gris claro) */}
<circle
cx={center}
cy={center}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
className="text-slate-100"
/>
{/* Círculo de Progreso (Verde/Color principal) */}
<circle
cx={center}
cy={center}
r={radius}
stroke="currentColor"
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className="text-primary transition-all duration-500 ease-out"
// Nota: usa text-primary para tomar el color de tu tema, o pon text-green-500
/>
</svg>
</div>
)
}

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // 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 rootRouteImport } from './routes/__root'
import { Route as Stepper2RouteImport } from './routes/stepper2'
import { Route as StepperRouteImport } from './routes/stepper' import { Route as StepperRouteImport } from './routes/stepper'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as DashboardRouteImport } from './routes/dashboard'
@@ -18,6 +19,11 @@ import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/rou
import { Route as PlanesPlanIdRouteRouteImport } from './routes/planes/$planId/route' import { Route as PlanesPlanIdRouteRouteImport } from './routes/planes/$planId/route'
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
const Stepper2Route = Stepper2RouteImport.update({
id: '/stepper2',
path: '/stepper2',
getParentRoute: () => rootRouteImport,
} as any)
const StepperRoute = StepperRouteImport.update({ const StepperRoute = StepperRouteImport.update({
id: '/stepper', id: '/stepper',
path: '/stepper', path: '/stepper',
@@ -64,6 +70,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/stepper': typeof StepperRoute '/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes': typeof PlanesListaRouteRouteWithChildren '/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -74,6 +81,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/stepper': typeof StepperRoute '/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes': typeof PlanesListaRouteRouteWithChildren '/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -85,6 +93,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRoute '/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/stepper': typeof StepperRoute '/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute '/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren '/planes/_lista': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -97,6 +106,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/login' | '/login'
| '/stepper' | '/stepper'
| '/stepper2'
| '/planes/$planId' | '/planes/$planId'
| '/planes' | '/planes'
| '/demo/tanstack-query' | '/demo/tanstack-query'
@@ -107,6 +117,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/login' | '/login'
| '/stepper' | '/stepper'
| '/stepper2'
| '/planes/$planId' | '/planes/$planId'
| '/planes' | '/planes'
| '/demo/tanstack-query' | '/demo/tanstack-query'
@@ -117,6 +128,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/login' | '/login'
| '/stepper' | '/stepper'
| '/stepper2'
| '/planes/$planId' | '/planes/$planId'
| '/planes/_lista' | '/planes/_lista'
| '/demo/tanstack-query' | '/demo/tanstack-query'
@@ -128,6 +140,7 @@ export interface RootRouteChildren {
DashboardRoute: typeof DashboardRoute DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
StepperRoute: typeof StepperRoute StepperRoute: typeof StepperRoute
Stepper2Route: typeof Stepper2Route
PlanesPlanIdRouteRoute: typeof PlanesPlanIdRouteRoute PlanesPlanIdRouteRoute: typeof PlanesPlanIdRouteRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
@@ -135,6 +148,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/stepper2': {
id: '/stepper2'
path: '/stepper2'
fullPath: '/stepper2'
preLoaderRoute: typeof Stepper2RouteImport
parentRoute: typeof rootRouteImport
}
'/stepper': { '/stepper': {
id: '/stepper' id: '/stepper'
path: '/stepper' path: '/stepper'
@@ -210,6 +230,7 @@ const rootRouteChildren: RootRouteChildren = {
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
StepperRoute: StepperRoute, StepperRoute: StepperRoute,
Stepper2Route: Stepper2Route,
PlanesPlanIdRouteRoute: PlanesPlanIdRouteRoute, PlanesPlanIdRouteRoute: PlanesPlanIdRouteRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren, PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute,

View File

@@ -172,7 +172,7 @@ function NuevoPlanModal() {
navigate({ to: '/planes', resetScroll: false }) navigate({ to: '/planes', resetScroll: false })
} }
// Derivados // --- LÓGICA DE VALIDACIÓN Y DATA ---
const carrerasFiltradas = useMemo(() => { const carrerasFiltradas = useMemo(() => {
const fac = wizard.datosBasicos.facultadId const fac = wizard.datosBasicos.facultadId
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS
@@ -202,7 +202,6 @@ function NuevoPlanModal() {
if (wizard.subModoClonado === 'TRADICIONAL') { if (wizard.subModoClonado === 'TRADICIONAL') {
const t = wizard.clonTradicional const t = wizard.clonTradicional
if (!t) return false if (!t) return false
// Reglas mínimas: Word + al menos un Excel
const tieneWord = !!t.archivoWordPlanId const tieneWord = !!t.archivoWordPlanId
const tieneAlMenosUnExcel = const tieneAlMenosUnExcel =
!!t.archivoMapaExcelId || !!t.archivoMateriasExcelId !!t.archivoMapaExcelId || !!t.archivoMateriasExcelId
@@ -236,7 +235,6 @@ function NuevoPlanModal() {
const crearPlan = async () => { const crearPlan = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null })) setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
await new Promise((r) => setTimeout(r, 900)) await new Promise((r) => setTimeout(r, 900))
// Elegimos un id ficticio distinto según modo
const nuevoId = (() => { const nuevoId = (() => {
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001' if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001' if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
@@ -245,16 +243,22 @@ function NuevoPlanModal() {
})() })()
navigate({ to: `/planes/${nuevoId}` }) navigate({ to: `/planes/${nuevoId}` })
} }
// ------------------------------------------------
return ( return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}> <Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="p-0 sm:max-w-[840px]"> {/* FIX LAYOUT:
<DialogHeader className="px-6 pt-6"> 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' ? (
// --- VISTA SIN PERMISOS ---
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle> <DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex-1 p-6">
{role !== 'JEFE_CARRERA' ? (
<div className="px-6 pb-6">
<Card className="border-destructive/40"> <Card className="border-destructive/40">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -272,55 +276,51 @@ function NuevoPlanModal() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
) : ( ) : (
<div className="px-6 pb-6"> // --- VISTA WIZARD ---
<Wizard.Stepper.Provider <Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id} initialStep={Wizard.utils.getFirst().id}
className="flex flex-col gap-6" className="flex h-full flex-col"
> >
{({ methods }) => ( {({ methods }) => (
<> <>
{/* Header + navegación */} {/* 1. HEADER (FIJO - Top Bun)
<Wizard.Stepper.Navigation className="border-border/60 rounded-xl border p-4"> flex-none: Asegura que no se aplaste ni haga scroll.
<Wizard.Stepper.Step of={Wizard.steps[0].id}> */}
<Wizard.Stepper.Title>1. Método</Wizard.Stepper.Title> <div className="z-10 flex-none border-b bg-white">
<Wizard.Stepper.Description> <DialogHeader className="p-6 pb-4">
Selecciona cómo crearás el plan <DialogTitle>Nuevo plan de estudios</DialogTitle>
</Wizard.Stepper.Description> </DialogHeader>
</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>
{/* Info de paso actual */} {/* Navegación dentro del bloque fijo */}
<div className="flex items-center justify-end px-1"> <div className="px-6 pb-6">
<span className="text-muted-foreground text-sm"> <Wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
Paso {Wizard.utils.getIndex(methods.current.id) + 1} de{' '} {/* Nota: En móvil esto se verá apretado. Considera overflow-x-auto si tienes muchos pasos */}
{Wizard.steps.length} <div className="flex items-center justify-between gap-2 overflow-x-auto">
</span> {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> </div>
{/* Panel activo (solo uno visible) */} {/* 2. CONTENIDO (SCROLLEABLE - Meat)
<div className="grid gap-6 md:grid-cols-2"> flex-1: Ocupa todo el espacio restante.
<div className="md:col-span-2"> 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.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoModo wizard={wizard} onChange={setWizard} /> <PasoModo wizard={wizard} onChange={setWizard} />
@@ -353,13 +353,20 @@ function NuevoPlanModal() {
</div> </div>
</div> </div>
{/* Controles */} {/* 3. CONTROLES (FIJO - Bottom Bun)
<Wizard.Stepper.Controls> 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 && ( {wizard.errorMessage && (
<span className="text-destructive mr-auto text-sm"> <span className="text-destructive text-sm font-medium">
{wizard.errorMessage} {wizard.errorMessage}
</span> </span>
)} )}
</div>
<div className="flex gap-4">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => methods.prev()} onClick={() => methods.prev()}
@@ -370,6 +377,7 @@ function NuevoPlanModal() {
> >
Anterior Anterior
</Button> </Button>
{Wizard.utils.getIndex(methods.current.id) < {Wizard.utils.getIndex(methods.current.id) <
Wizard.steps.length - 1 ? ( Wizard.steps.length - 1 ? (
<Button <Button
@@ -391,11 +399,12 @@ function NuevoPlanModal() {
Crear plan Crear plan
</Button> </Button>
)} )}
</div>
</Wizard.Stepper.Controls> </Wizard.Stepper.Controls>
</div>
</> </>
)} )}
</Wizard.Stepper.Provider> </Wizard.Stepper.Provider>
</div>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -506,7 +515,7 @@ function PasoBasicos({
carrerasFiltradas: typeof CARRERAS carrerasFiltradas: typeof CARRERAS
}) { }) {
return ( 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"> <div className="sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label> <Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input <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>
)
}