wip
This commit is contained in:
74
src/components/CircularProgress.tsx
Normal file
74
src/components/CircularProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
82
src/routes/stepper2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user