feat: implement asignaturas management with dynamic routing and UI updates

This commit is contained in:
2025-08-22 08:10:49 -06:00
parent 8f46acd4b3
commit 9727f4c691
8 changed files with 249 additions and 212 deletions

View File

@@ -1,6 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router' import { RouterProvider, createRouteMask, createRouter } from '@tanstack/react-router'
// Import the generated route tree // Import the generated route tree
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
@@ -15,7 +15,7 @@ const router = createRouter({
scrollRestoration: true, scrollRestoration: true,
defaultStructuralSharing: true, defaultStructuralSharing: true,
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
context:{ context: {
auth: undefined!, auth: undefined!,
}, },
}) })

View File

@@ -16,10 +16,9 @@ import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticat
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes' import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades' import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId' import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId' import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
import { Route as AuthenticatedPlanPlanIdModalRouteImport } from './routes/_authenticated/plan/$planId/modal' import { Route as AuthenticatedAsignaturasPlanIdRouteImport } from './routes/_authenticated/asignaturas/$planId'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
@@ -55,12 +54,6 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
path: '/dashboard', path: '/dashboard',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedAsignaturasRoute =
AuthenticatedAsignaturasRouteImport.update({
id: '/asignaturas',
path: '/asignaturas',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPlanPlanIdRoute = AuthenticatedPlanPlanIdRouteImport.update({ const AuthenticatedPlanPlanIdRoute = AuthenticatedPlanPlanIdRouteImport.update({
id: '/plan/$planId', id: '/plan/$planId',
path: '/plan/$planId', path: '/plan/$planId',
@@ -72,89 +65,83 @@ const AuthenticatedFacultadFacultadIdRoute =
path: '/facultad/$facultadId', path: '/facultad/$facultadId',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedPlanPlanIdModalRoute = const AuthenticatedAsignaturasPlanIdRoute =
AuthenticatedPlanPlanIdModalRouteImport.update({ AuthenticatedAsignaturasPlanIdRouteImport.update({
id: '/modal', id: '/asignaturas/$planId',
path: '/modal', path: '/asignaturas/$planId',
getParentRoute: () => AuthenticatedPlanPlanIdRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/asignaturas': typeof AuthenticatedAsignaturasRoute
'/dashboard': typeof AuthenticatedDashboardRoute '/dashboard': typeof AuthenticatedDashboardRoute
'/facultades': typeof AuthenticatedFacultadesRoute '/facultades': typeof AuthenticatedFacultadesRoute
'/planes': typeof AuthenticatedPlanesRoute '/planes': typeof AuthenticatedPlanesRoute
'/usuarios': typeof AuthenticatedUsuariosRoute '/usuarios': typeof AuthenticatedUsuariosRoute
'/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren '/plan/$planId': typeof AuthenticatedPlanPlanIdRoute
'/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/asignaturas': typeof AuthenticatedAsignaturasRoute
'/dashboard': typeof AuthenticatedDashboardRoute '/dashboard': typeof AuthenticatedDashboardRoute
'/facultades': typeof AuthenticatedFacultadesRoute '/facultades': typeof AuthenticatedFacultadesRoute
'/planes': typeof AuthenticatedPlanesRoute '/planes': typeof AuthenticatedPlanesRoute
'/usuarios': typeof AuthenticatedUsuariosRoute '/usuarios': typeof AuthenticatedUsuariosRoute
'/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren '/plan/$planId': typeof AuthenticatedPlanPlanIdRoute
'/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute '/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
'/_authenticated/planes': typeof AuthenticatedPlanesRoute '/_authenticated/planes': typeof AuthenticatedPlanesRoute
'/_authenticated/usuarios': typeof AuthenticatedUsuariosRoute '/_authenticated/usuarios': typeof AuthenticatedUsuariosRoute
'/_authenticated/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute
'/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/_authenticated/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren '/_authenticated/plan/$planId': typeof AuthenticatedPlanPlanIdRoute
'/_authenticated/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/' | '/'
| '/login' | '/login'
| '/asignaturas'
| '/dashboard' | '/dashboard'
| '/facultades' | '/facultades'
| '/planes' | '/planes'
| '/usuarios' | '/usuarios'
| '/asignaturas/$planId'
| '/facultad/$facultadId' | '/facultad/$facultadId'
| '/plan/$planId' | '/plan/$planId'
| '/plan/$planId/modal'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/login' | '/login'
| '/asignaturas'
| '/dashboard' | '/dashboard'
| '/facultades' | '/facultades'
| '/planes' | '/planes'
| '/usuarios' | '/usuarios'
| '/asignaturas/$planId'
| '/facultad/$facultadId' | '/facultad/$facultadId'
| '/plan/$planId' | '/plan/$planId'
| '/plan/$planId/modal'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/_authenticated/asignaturas'
| '/_authenticated/dashboard' | '/_authenticated/dashboard'
| '/_authenticated/facultades' | '/_authenticated/facultades'
| '/_authenticated/planes' | '/_authenticated/planes'
| '/_authenticated/usuarios' | '/_authenticated/usuarios'
| '/_authenticated/asignaturas/$planId'
| '/_authenticated/facultad/$facultadId' | '/_authenticated/facultad/$facultadId'
| '/_authenticated/plan/$planId' | '/_authenticated/plan/$planId'
| '/_authenticated/plan/$planId/modal'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -214,13 +201,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedDashboardRouteImport preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/asignaturas': {
id: '/_authenticated/asignaturas'
path: '/asignaturas'
fullPath: '/asignaturas'
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/plan/$planId': { '/_authenticated/plan/$planId': {
id: '/_authenticated/plan/$planId' id: '/_authenticated/plan/$planId'
path: '/plan/$planId' path: '/plan/$planId'
@@ -235,48 +215,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/plan/$planId/modal': { '/_authenticated/asignaturas/$planId': {
id: '/_authenticated/plan/$planId/modal' id: '/_authenticated/asignaturas/$planId'
path: '/modal' path: '/asignaturas/$planId'
fullPath: '/plan/$planId/modal' fullPath: '/asignaturas/$planId'
preLoaderRoute: typeof AuthenticatedPlanPlanIdModalRouteImport preLoaderRoute: typeof AuthenticatedAsignaturasPlanIdRouteImport
parentRoute: typeof AuthenticatedPlanPlanIdRoute parentRoute: typeof AuthenticatedRoute
} }
} }
} }
interface AuthenticatedPlanPlanIdRouteChildren {
AuthenticatedPlanPlanIdModalRoute: typeof AuthenticatedPlanPlanIdModalRoute
}
const AuthenticatedPlanPlanIdRouteChildren: AuthenticatedPlanPlanIdRouteChildren =
{
AuthenticatedPlanPlanIdModalRoute: AuthenticatedPlanPlanIdModalRoute,
}
const AuthenticatedPlanPlanIdRouteWithChildren =
AuthenticatedPlanPlanIdRoute._addFileChildren(
AuthenticatedPlanPlanIdRouteChildren,
)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRoute
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
AuthenticatedUsuariosRoute: typeof AuthenticatedUsuariosRoute AuthenticatedUsuariosRoute: typeof AuthenticatedUsuariosRoute
AuthenticatedAsignaturasPlanIdRoute: typeof AuthenticatedAsignaturasPlanIdRoute
AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute
AuthenticatedPlanPlanIdRoute: typeof AuthenticatedPlanPlanIdRouteWithChildren AuthenticatedPlanPlanIdRoute: typeof AuthenticatedPlanPlanIdRoute
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute, AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
AuthenticatedPlanesRoute: AuthenticatedPlanesRoute, AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
AuthenticatedUsuariosRoute: AuthenticatedUsuariosRoute, AuthenticatedUsuariosRoute: AuthenticatedUsuariosRoute,
AuthenticatedAsignaturasPlanIdRoute: AuthenticatedAsignaturasPlanIdRoute,
AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute, AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute,
AuthenticatedPlanPlanIdRoute: AuthenticatedPlanPlanIdRouteWithChildren, AuthenticatedPlanPlanIdRoute: AuthenticatedPlanPlanIdRoute,
} }
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(

View File

@@ -1,9 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/asignaturas')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_authenticated/asignaturas"!</div>
}

View File

@@ -0,0 +1,142 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
type Asignatura = {
id: string
nombre: string
semestre: number | null
creditos: number | null
horas_teoricas: number | null
horas_practicas: number | null
}
type ModalData = {
planId: string
planNombre: string
asignaturas: Asignatura[]
}
export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({
component: ModalComponent,
loader: async ({ params }): Promise<ModalData> => {
const planId = params.planId
const { data: plan, error: planErr } = await supabase
.from("plan_estudios")
.select("id, nombre")
.eq("id", planId)
.single()
if (planErr || !plan) throw planErr ?? new Error("Plan no encontrado")
const { data: asignaturas, error: aErr } = await supabase
.from("asignaturas")
.select("id, nombre, semestre, creditos, horas_teoricas, horas_practicas")
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
if (aErr) throw aErr
return {
planId,
planNombre: plan.nombre,
asignaturas: (asignaturas ?? []) as Asignatura[],
}
},
})
function ModalComponent() {
const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData
const router = useRouter()
const [q, setQ] = useState("")
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return asignaturas
return asignaturas.filter(a =>
[a.nombre, a.semestre, a.creditos]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
)
}, [q, asignaturas])
// Agrupar por semestre
const groups = useMemo(() => {
const m = new Map<number | string, Asignatura[]>()
for (const a of filtered) {
const k = a.semestre ?? "—"
if (!m.has(k)) m.set(k, [])
m.get(k)!.push(a)
}
return Array.from(m.entries()).sort(([a], [b]) => {
if (a === "—") return 1
if (b === "—") return -1
return Number(a) - Number(b)
})
}, [filtered])
return (
<Dialog
open
onOpenChange={() =>
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
}
>
<DialogContent className="w-[min(92vw,900px)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.BookOpen className="w-5 h-5" />
Asignaturas · <span className="font-normal">{planNombre}</span>
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2 pb-3">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre, semestre…"
className="w-full"
/>
</div>
<div className="max-h-[65vh] overflow-auto pr-1">
{groups.length === 0 && (
<div className="text-sm text-neutral-500 py-8 text-center">Sin asignaturas</div>
)}
<div className="space-y-5">
{groups.map(([sem, items]) => (
<div key={String(sem)}>
<div className="mb-2 text-xs font-semibold text-neutral-500">
Semestre {sem}
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{items.map(a => (
<li key={a.id} className="rounded-xl border p-3 bg-white/70 dark:bg-neutral-900/60">
<div className="font-medium truncate">{a.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
{a.creditos != null && (
<Badge variant="outline" className="bg-white/60">Créditos: {a.creditos}</Badge>
)}
{(a.horas_teoricas ?? 0) + (a.horas_practicas ?? 0) > 0 && (
<Badge variant="secondary" className="bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100">
Hrs T/P: {a.horas_teoricas ?? 0}/{a.horas_practicas ?? 0}
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,6 +5,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase' import { supabase } from '@/auth/supabase'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTheme } from '@/components/theme-provider'
type Facultad = { id: string; nombre: string; icon: string; color?: string | null } type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
type Plan = { type Plan = {
@@ -121,13 +122,14 @@ function gradientFrom(color?: string | null) {
} }
// ====== UI helpers ====== // ====== UI helpers ======
function ProgressRing({ pct }: { pct: number }) { function ProgressRing({ pct, color }: { pct: number, color: string }) {
const r = 42, c = 2 * Math.PI * r const r = 42, c = 2 * Math.PI * r
const offset = c * (1 - Math.min(Math.max(pct, 0), 100) / 100) const offset = c * (1 - Math.min(Math.max(pct, 0), 100) / 100)
// Puedes ajustar el color del stroke según el tema
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<svg width="112" height="112" viewBox="0 0 112 112" className="drop-shadow"> <svg width="112" height="112" viewBox="0 0 112 112" className="drop-shadow">
<circle cx="56" cy="56" r={r} fill="none" stroke="rgba(0,0,0,.08)" strokeWidth="12" /> <circle cx="56" cy="56" r={r} fill="none" stroke={color} strokeWidth="12" />
<circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12" <circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12"
strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round" strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round"
transform="rotate(-90 56 56)" /> transform="rotate(-90 56 56)" />
@@ -143,7 +145,7 @@ function ProgressRing({ pct }: { pct: number }) {
function HealthItem({ label, value, to }: { label: string; value: number; to: string }) { function HealthItem({ label, value, to }: { label: string; value: number; to: string }) {
const warn = value > 0 const warn = value > 0
return ( return (
<Link to={to} className={`flex items-center justify-between rounded-xl px-4 py-3 border ${warn ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-neutral-200 bg-white'}`}> <Link to={to} className={`flex items-center justify-between rounded-xl px-4 py-3 border ${warn ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-neutral-200'}`}>
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>
<span className="text-lg font-semibold tabular-nums">{value}</span> <span className="text-lg font-semibold tabular-nums">{value}</span>
</Link> </Link>
@@ -158,7 +160,7 @@ function RouteComponent() {
return ( return (
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{/* Header */} {/* Header */}
<div className="relative rounded-3xl overflow-hidden text-white shadow-xl" style={headerBg}> <div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={headerBg}>
<div className="absolute inset-0 opacity-20" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} /> <div className="absolute inset-0 opacity-20" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
<div className="relative p-8 flex items-center gap-5"> <div className="relative p-8 flex items-center gap-5">
<HeaderIcon className="w-16 h-16 md:w-20 md:h-20 drop-shadow" /> <HeaderIcon className="w-16 h-16 md:w-20 md:h-20 drop-shadow" />
@@ -179,14 +181,14 @@ function RouteComponent() {
{/* Calidad + Salud */} {/* Calidad + Salud */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 lg:col-span-2"> <div className="rounded-2xl shadow-lg ring-1 ring-black/5 p-5 lg:col-span-2">
<div className="font-semibold mb-3">Calidad de planes</div> <div className="font-semibold mb-3">Calidad de planes</div>
<ProgressRing pct={calidadPlanesPct} /> <ProgressRing pct={calidadPlanesPct} color={facultad.color || 'white'}/>
<div className="mt-3 text-sm text-neutral-600"> <div className="mt-3 text-sm text-neutral-600">
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>. Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
</div> </div>
</div> </div>
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5"> <div className="rounded-2xl shadow-lg ring-1 ring-black/5 p-5">
<div className="font-semibold mb-3">Salud de asignaturas</div> <div className="font-semibold mb-3">Salud de asignaturas</div>
<div className="space-y-2"> <div className="space-y-2">
<HealthItem label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinBibliografia`} /> <HealthItem label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinBibliografia`} />
@@ -197,7 +199,7 @@ function RouteComponent() {
</div> </div>
{/* Actividad reciente */} {/* Actividad reciente */}
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5"> <div className="rounded-2xl shadow-lg ring-1 ring-black/5 p-5">
<div className="font-semibold mb-3">Actividad reciente</div> <div className="font-semibold mb-3">Actividad reciente</div>
<ul className="space-y-2"> <ul className="space-y-2">
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>} {recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
@@ -221,12 +223,12 @@ function RouteComponent() {
// Tarjeta métrica (igual a tu StatTile) // Tarjeta métrica (igual a tu StatTile)
function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) { function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) {
return ( return (
<Link to={to} className="group rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all"> <Link to={to} className="group rounded-2xl shadow-lg ring-1 ring-black/5 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
<div> <div>
<div className="text-sm text-neutral-500">{label}</div> <div className="text-sm text-neutral-500">{label}</div>
<div className="text-3xl font-bold tabular-nums">{value}</div> <div className="text-3xl font-bold tabular-nums">{value}</div>
</div> </div>
<div className="p-3 rounded-xl bg-neutral-100 group-hover:bg-neutral-200"> <div className="p-3 rounded-xl bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200">
<Icon className="w-7 h-7" /> <Icon className="w-7 h-7" />
</div> </div>
</Link> </Link>

View File

@@ -1,4 +1,4 @@
import { createFileRoute, Link, useParams } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { supabase, useSupabaseAuth } from '@/auth/supabase' import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -25,7 +25,8 @@ type PlanFull = {
estado: string | null; fecha_creacion: string | null; estado: string | null; fecha_creacion: string | null;
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
} }
type LoaderData = { plan: PlanFull; asignaturasCount: number } type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] }
/* ============== ROUTE ============== */ /* ============== ROUTE ============== */
export const Route = createFileRoute('/_authenticated/plan/$planId')({ export const Route = createFileRoute('/_authenticated/plan/$planId')({
@@ -49,7 +50,19 @@ export const Route = createFileRoute('/_authenticated/plan/$planId')({
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
.eq('plan_id', params.planId) .eq('plan_id', params.planId)
return { plan: plan as unknown as PlanFull, asignaturasCount: count ?? 0 } const { data: asignaturasPreview } = await supabase
.from('asignaturas')
.select('id, nombre, semestre, creditos')
.eq('plan_id', params.planId)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
.limit(8)
return {
plan: plan as unknown as PlanFull,
asignaturasCount: count ?? 0,
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[],
}
}, },
}) })
@@ -112,7 +125,7 @@ function GradientMesh({ color }: { color?: string | null }) {
/* ============== PAGE ============== */ /* ============== PAGE ============== */
function RouteComponent() { function RouteComponent() {
const { plan, asignaturasCount } = Route.useLoaderData() as LoaderData const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria' const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico' const showCarrera = auth.claims?.role === 'secretario_academico'
@@ -144,7 +157,7 @@ function RouteComponent() {
// Stats y campos con ScrollTrigger // Stats y campos con ScrollTrigger
if (statsRef.current) { if (statsRef.current) {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
gsap.from('.kv', { gsap.from('.academics', {
y: 14, opacity: 0, stagger: .08, duration: .4, y: 14, opacity: 0, stagger: .08, duration: .4,
scrollTrigger: { trigger: statsRef.current, start: 'top 85%' } scrollTrigger: { trigger: statsRef.current, start: 'top 85%' }
}) })
@@ -204,8 +217,8 @@ function RouteComponent() {
</Badge> </Badge>
)} )}
<Link <Link
to="/asignaturas" to="/asignaturas/$planId"
search={{ planId: plan.id }} params={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50" className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
> >
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas <Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
@@ -220,23 +233,58 @@ function RouteComponent() {
{/* stats */} {/* stats */}
<CardContent <CardContent
ref={statsRef} ref={statsRef}
className="relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]"
> >
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} /> <div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} /> <StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
<StatCard label="Asignaturas" value={fmt(asignaturasCount)} Icon={Icons.BookOpen} accent={facColor} /> <StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard <StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
label="Creado" <StatCard
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} label="Creado"
Icon={Icons.CalendarDays} value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
accent={facColor} Icon={Icons.CalendarDays}
/> accent={facColor}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} /> <div className="academics">
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
</div>
<Card className="border shadow-sm">
<CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
{/* Abre el modal enmascarado */}
<Link
to="/asignaturas/$planId"
params={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver todas
</Link>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{asignaturasPreview.length === 0 && (
<div className="text-sm text-neutral-500">Sin asignaturas</div>
)}
{asignaturasPreview.map(a => (
<Link
key={a.id}
to="/asignaturas/$planId"
params={{ planId: plan.id }}
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
title={a.nombre}
>
{a.semestre ? `S${a.semestre} · ` : ''}{a.nombre}
</Link>
))}
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -1,112 +0,0 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
type PlanDetail = {
id: string
nombre: string
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
carreras: {
id: string
nombre: string
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
} | null
}
export const Route = createFileRoute('/_authenticated/plan/$planId/modal')({
component: RouteComponent,
loader: async ({ params }) => {
const { data, error } = await supabase
.from('plan_estudios')
.select(`
id, nombre, nivel, duracion, total_creditos, estado,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`)
.eq('id', params.planId)
.single()
if (error) throw error
return data
},
})
function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) ? color : "#2563eb"
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
}
function RouteComponent() {
const plan = Route.useLoaderData() as PlanDetail
const router = useRouter()
const fac = plan.carreras?.facultades
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
const headerBg = { background: gradientFrom(fac?.color) }
return (
<Dialog
open
onOpenChange={() =>
router.navigate({
to: '/plan/$planId',
params: { planId: plan.id },
replace: true,
})
}
>
<DialogContent className="max-w-2xl p-0 overflow-hidden" aria-describedby="">
{/* Header con color/ícono de facultad */}
<div className="p-6 text-white" style={headerBg}>
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl bg-white/15 backdrop-blur px-3 py-2">
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<DialogHeader>
<DialogTitle className="truncate">{plan.nombre}</DialogTitle>
</DialogHeader>
<div className="text-xs opacity-90 truncate">
{plan.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
</div>
</div>
{plan.estado && (
<Badge variant="outline" className="ml-auto bg-white/10 text-white border-white/40">
{plan.estado}
</Badge>
)}
</div>
</div>
{/* Cuerpo */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
<div><span className="text-neutral-500">Facultad:</span> <span className="font-medium">{fac?.nombre ?? "—"}</span></div>
</div>
<div className="flex gap-2">
<a
href={`/_authenticated/planes/${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
>
<Icons.FileText className="w-4 h-4" /> Ver ficha
</a>
<a
href={`/_authenticated/asignaturas?planId=${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</a>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -122,7 +122,7 @@ function RouteComponent() {
return ( return (
<Link <Link
key={p.id} key={p.id}
to="/plan/$planId/modal" to="/plan/$planId"
mask={{ to: '/plan/$planId', params: { planId: p.id } }} mask={{ to: '/plan/$planId', params: { planId: p.id } }}
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5" className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
params={{ planId: p.id }} params={{ planId: p.id }}