This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/routes/_authenticated/dashboard.tsx
Guillermo Arrieta Medina f2b3010ac9 Ahora se obtienen claims de las tablas en el esquema public, en vez de la información de sesion del usuario, que se obtiene de la tabla auth.users
En supabase.tsx se sustituyó la manera de obtener los claims del usuario, utilizando ahora un rpc de una función en supabase.
2025-10-10 17:23:37 -06:00

381 lines
15 KiB
TypeScript

import { createFileRoute, Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
/* ========= Tipos ========= */
type Plan = {
id: string
nombre: string
fecha_creacion: string | null
objetivo_general: string | null
perfil_ingreso: string | null
perfil_egreso: string | null
sistema_evaluacion: string | null
total_creditos: number | null
}
type Asignatura = {
id: string
nombre: string
fecha_creacion: string | null
contenidos: any | null
criterios_evaluacion: string | null
bibliografia: any | null
}
type LoaderData = {
kpis: { facultades: number; carreras: number; planes: number; asignaturas: number }
calidadPlanesPct: number
saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number }
recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }>
}
/* ========= Query Key & Fetcher ========= */
const dashboardKeys = {
root: ['dashboard'] as const,
summary: () => [...dashboardKeys.root, 'summary'] as const,
}
async function fetchDashboard(): Promise<LoaderData> {
const [facRes, carRes, planesRes, asigRes] = await Promise.all([
supabase.from('facultades').select('*', { count: 'exact', head: true }),
supabase.from('carreras').select('*', { count: 'exact', head: true }),
supabase
.from('plan_estudios')
.select(
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
),
supabase
.from('asignaturas')
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia'),
])
const planes = (planesRes.data ?? []) as Plan[]
const asignaturas = (asigRes.data ?? []) as Asignatura[]
// Calidad de planes
const needed: (keyof Plan)[] = [
'objetivo_general',
'perfil_ingreso',
'perfil_egreso',
'sistema_evaluacion',
'total_creditos',
]
const completos = planes.filter((p) => needed.every((k) => p[k] !== null && String(p[k] ?? '').trim() !== '')).length
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
// Salud de asignaturas
const sinBibliografia = asignaturas.filter(
(a) => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
).length
const sinCriterios = asignaturas.filter((a) => !a.criterios_evaluacion?.trim()).length
const sinContenidos = asignaturas.filter(
(a) => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
).length
// Actividad reciente (últimos 8 ítems)
const recientes = [
...planes.map((p) => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })),
...asignaturas.map((a) => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion })),
]
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
.slice(0, 8)
return {
kpis: {
facultades: facRes.count ?? 0,
carreras: carRes.count ?? 0,
planes: planes.length,
asignaturas: asignaturas.length,
},
calidadPlanesPct,
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
recientes,
}
}
const dashboardOptions = () =>
queryOptions({ queryKey: dashboardKeys.summary(), queryFn: fetchDashboard, staleTime: 30_000 })
/* ========= Ruta ========= */
export const Route = createFileRoute('/_authenticated/dashboard')({
component: RouteComponent,
pendingComponent: DashboardSkeleton,
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(dashboardOptions())
return null
},
})
/* ========= Helpers visuales ========= */
function gradient(bg = '#2563eb') {
return { background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)` } as React.CSSProperties
}
function hex(color?: string | null, fallback = '#2563eb') {
return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback
}
function Ring({ pct, color }: { pct: number; color: string }) {
const R = 42
const C = 2 * Math.PI * R
const off = C * (1 - Math.min(Math.max(pct, 0), 100) / 100)
return (
<div className="flex items-center gap-4">
<svg width="112" height="112" viewBox="0 0 112 112">
<circle cx="56" cy="56" r={R} fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="56"
cy="56"
r={R}
fill="none"
stroke={color}
strokeWidth="12"
strokeDasharray={C}
strokeDashoffset={off}
strokeLinecap="round"
transform="rotate(-90 56 56)"
/>
</svg>
<div>
<div className="text-3xl font-bold tabular-nums">{pct}%</div>
<div className="text-sm text-neutral-600">Planes con información clave completa</div>
</div>
</div>
)
}
function Tile({ to, label, value, Icon }: { to: string; label: string; value: number | string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> }) {
return (
<Link to={to} className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
<div>
<div className="text-sm text-neutral-500">{label}</div>
<div className="text-3xl font-bold tabular-nums">{value}</div>
</div>
<div className="p-3 rounded-xl bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200">
<Icon className="w-7 h-7" />
</div>
</Link>
)
}
/* ========= Página ========= */
function RouteComponent() {
const { data } = useSuspenseQuery(dashboardOptions())
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = data
const auth = useSupabaseAuth()
const router = useRouter()
const qc = useQueryClient()
const primary = hex(auth.claims?.facultad_color, '#1d4ed8')
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
const isAdmin = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
const navigate = useNavigate({ from: Route.fullPath })
// Mensaje contextual
const roleHint = useMemo(() => {
switch (role) {
case 'vicerrectoria':
return 'Panorama académico, calidad y actividad reciente.'
case 'secretario_academico':
return 'Enfócate en tu facultad: salud de asignaturas y avance de planes.'
case 'jefe_carrera':
return 'Accede rápido a planes y asignaturas de tu carrera.'
case 'planeacion':
return 'Monitorea consistencia de planes y evidencias de evaluación.'
default:
return 'Atajos para crear, revisar y mejorar contenido.'
}
}, [role])
return (
<div className="p-6 space-y-8">
{/* Header con saludo y búsqueda global */}
<div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}>
<div className="absolute inset-0 opacity-25" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
<div className="relative p-6 md:p-8 flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl md:text-3xl font-bold leading-tight">Hola, {name}</h1>
<p className="opacity-95">{roleHint}</p>
</div>
<div className="flex items-center gap-2">
{role && (
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{role}
</Badge>
)}
{isAdmin && (
<Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1">
<Icons.ShieldCheck className="w-3.5 h-3.5" /> admin
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 w-full">
<Input
placeholder="Buscar planes, asignaturas o personas… (Enter)"
className="bg-white/90 text-neutral-800 placeholder:text-neutral-400"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const q = (e.target as HTMLInputElement).value.trim()
if (!q) return
navigate({ to: '/planes', search: { plan: q } })
}
}}
/>
<Button
variant="secondary"
className="bg-white/20 text-white hover:bg-white/30 border-white/30"
onClick={async () => {
await qc.invalidateQueries({ queryKey: dashboardKeys.root })
router.invalidate()
}}
title="Actualizar"
>
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
</div>
{/* Atajos rápidos (según rol) */}
<div className="flex flex-wrap gap-2">
<Link to="/planes" search={{ plan: '' }} className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
<Icons.ScrollText className="w-4 h-4" /> Nuevo plan
</Link>
<Link
to="/asignaturas"
search={{ carreraId: '', f: '', facultadId: '', planId: '', q: '' }}
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.BookOpen className="w-4 h-4" /> Nueva asignatura
</Link>
{isAdmin && (
<Link to="/usuarios" className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
<Icons.UserPlus className="w-4 h-4" /> Invitar usuario
</Link>
)}
</div>
</div>
</div>
{/* KPIs principales */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Tile to="/facultades" label="Facultades" value={kpis.facultades} Icon={Icons.Building2} />
<Tile to="/carreras" label="Carreras" value={kpis.carreras} Icon={Icons.GraduationCap} />
<Tile to="/planes" label="Planes de estudio" value={kpis.planes} Icon={Icons.ScrollText} />
<Tile to="/asignaturas" label="Asignaturas" value={kpis.asignaturas} Icon={Icons.BookOpen} />
</div>
{/* Calidad + Salud */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 font-mono">
<Icons.CheckCircle2 className="w-5 h-5" /> Calidad de planes
</CardTitle>
</CardHeader>
<CardContent>
<Ring pct={calidadPlanesPct} color={hex(auth.claims?.facultad_color, '#2563eb')} />
<p className="mt-3 text-sm text-neutral-600">
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 font-mono">
<Icons.HeartPulse className="w-5 h-5" /> Salud de asignaturas
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<HealthRow to="/_authenticated/asignaturas?f=sinBibliografia" label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} />
<HealthRow to="/_authenticated/asignaturas?f=sinCriterios" label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} />
<HealthRow to="/_authenticated/asignaturas?f=sinContenidos" label="Sin contenidos" value={saludAsignaturas.sinContenidos} />
</CardContent>
</Card>
</div>
{/* Actividad reciente */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 font-mono">
<Icons.Activity className="w-5 h-5" /> Actividad reciente
</CardTitle>
</CardHeader>
<CardContent>
{recientes.length === 0 && <div className="text-sm text-neutral-500">Sin actividad registrada.</div>}
<ul className="divide-y">
{recientes.map((r) => (
<li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3">
<Link
to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'}
params={r.tipo === 'plan' ? { planId: r.id } : { asignaturaId: r.id }}
className="truncate inline-flex items-center gap-2 hover:underline"
title={r.nombre}
>
{r.tipo === 'plan' ? (
<Icons.ScrollText className="w-4 h-4 text-neutral-500" />
) : (
<Icons.BookOpen className="w-4 h-4 text-neutral-500" />
)}
<span className="truncate">{r.nombre}</span>
</Link>
<span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
</li>
))}
</ul>
</CardContent>
</Card>
</div>
)
}
/* ========= Subcomponentes ========= */
function HealthRow({ label, value, to }: { label: string; value: number; to: string }) {
const warn = value > 0
return (
<Link
to={to}
className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${warn ? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100' : 'ring-neutral-200 hover:bg-neutral-50'
} transition-colors`}
>
<span className="text-sm">{label}</span>
<span className="text-lg font-semibold tabular-nums">{value}</span>
</Link>
)
}
/* ========= Skeleton (cuando carga) ========= */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
}
function DashboardSkeleton() {
return (
<div className="p-6 space-y-8">
<div className="rounded-3xl p-8">
<Pulse className="h-24" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Pulse key={i} className="h-28" />
))}
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Pulse className="h-64 lg:col-span-2" />
<Pulse className="h-64" />
</div>
<Pulse className="h-72" />
</div>
)
}