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
Alejandro Rosales 5a113ca603 feat: add CarreraDetailDialog and CriterioFormDialog components for managing carrera criteria
feat: implement CarreraFormDialog for creating and editing carreras

feat: create StatusPill component for active/inactive status display

feat: add openContextMenu utility for context menu interactions

feat: add tint utility function for color manipulation

refactor: update archivos route to use font-mono for CardTitle

refactor: update asignaturas route to use font-mono for headings

refactor: update carreras route to modularize components and improve readability

refactor: update dashboard route to use font-mono for CardTitle

refactor: update plan detail route to use font-mono for CardTitle

refactor: update planes route to use font-mono for CardTitle

refactor: update usuarios route to use font-mono for CardTitle

refactor: update login route to use font-mono for CardTitle
2025-09-01 08:43:41 -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?.claims_admin
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>
)
}