En supabase.tsx se sustituyó la manera de obtener los claims del usuario, utilizando ahora un rpc de una función en supabase.
381 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|