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.
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'
@@ -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 PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
const Stepper2Route = Stepper2RouteImport.update({
id: '/stepper2',
path: '/stepper2',
getParentRoute: () => rootRouteImport,
} as any)
const StepperRoute = StepperRouteImport.update({
id: '/stepper',
path: '/stepper',
@@ -64,6 +70,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -74,6 +81,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -85,6 +93,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/stepper': typeof StepperRoute
'/stepper2': typeof Stepper2Route
'/planes/$planId': typeof PlanesPlanIdRouteRoute
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
@@ -97,6 +106,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/planes/$planId'
| '/planes'
| '/demo/tanstack-query'
@@ -107,6 +117,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/planes/$planId'
| '/planes'
| '/demo/tanstack-query'
@@ -117,6 +128,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/login'
| '/stepper'
| '/stepper2'
| '/planes/$planId'
| '/planes/_lista'
| '/demo/tanstack-query'
@@ -128,6 +140,7 @@ export interface RootRouteChildren {
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
StepperRoute: typeof StepperRoute
Stepper2Route: typeof Stepper2Route
PlanesPlanIdRouteRoute: typeof PlanesPlanIdRouteRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
@@ -135,6 +148,13 @@ export interface RootRouteChildren {
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'
@@ -210,6 +230,7 @@ const rootRouteChildren: RootRouteChildren = {
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
StepperRoute: StepperRoute,
Stepper2Route: Stepper2Route,
PlanesPlanIdRouteRoute: PlanesPlanIdRouteRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,

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,16 +243,22 @@ 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">
{/* 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' ? (
// --- VISTA SIN PERMISOS ---
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
{role !== 'JEFE_CARRERA' ? (
<div className="px-6 pb-6">
<div className="flex-1 p-6">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -272,55 +276,51 @@ function NuevoPlanModal() {
</CardContent>
</Card>
</div>
</>
) : (
<div className="px-6 pb-6">
// --- VISTA WIZARD ---
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex flex-col gap-6"
className="flex h-full flex-col"
>
{({ 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>
{/* 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">
{/* 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} />
@@ -353,13 +353,20 @@ function NuevoPlanModal() {
</div>
</div>
{/* Controles */}
<Wizard.Stepper.Controls>
{/* 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 mr-auto text-sm">
<span className="text-destructive text-sm font-medium">
{wizard.errorMessage}
</span>
)}
</div>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
@@ -370,6 +377,7 @@ function NuevoPlanModal() {
>
Anterior
</Button>
{Wizard.utils.getIndex(methods.current.id) <
Wizard.steps.length - 1 ? (
<Button
@@ -391,11 +399,12 @@ function NuevoPlanModal() {
Crear plan
</Button>
)}
</div>
</Wizard.Stepper.Controls>
</div>
</>
)}
</Wizard.Stepper.Provider>
</div>
)}
</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>
)
}