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,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 * as Icons from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -25,7 +25,8 @@ type PlanFull = {
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
}
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 ============== */
export const Route = createFileRoute('/_authenticated/plan/$planId')({
@@ -49,7 +50,19 @@ export const Route = createFileRoute('/_authenticated/plan/$planId')({
.select('*', { count: 'exact', head: true })
.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 ============== */
function RouteComponent() {
const { plan, asignaturasCount } = Route.useLoaderData() as LoaderData
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico'
@@ -144,7 +157,7 @@ function RouteComponent() {
// Stats y campos con ScrollTrigger
if (statsRef.current) {
const ctx = gsap.context(() => {
gsap.from('.kv', {
gsap.from('.academics', {
y: 14, opacity: 0, stagger: .08, duration: .4,
scrollTrigger: { trigger: statsRef.current, start: 'top 85%' }
})
@@ -204,8 +217,8 @@ function RouteComponent() {
</Badge>
)}
<Link
to="/asignaturas"
search={{ planId: plan.id }}
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 asignaturas
@@ -220,23 +233,58 @@ function RouteComponent() {
{/* stats */}
<CardContent
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} />
<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="Asignaturas" value={fmt(asignaturasCount)} Icon={Icons.BookOpen} accent={facColor} />
<StatCard
label="Creado"
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
Icon={Icons.CalendarDays}
accent={facColor}
/>
<div className="academics 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} />
<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="Creado"
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
Icon={Icons.CalendarDays}
accent={facColor}
/>
</div>
</CardContent>
</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>
)
}

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>
)
}