51faa98022
- Added a new route for managing faculties with a grid display of faculties. - Created a detailed view for each faculty including metrics and recent activities. - Introduced a new loader for fetching faculty data and associated plans and subjects. - Enhanced the existing plans route to include a modal for plan details. - Updated the login and index pages with improved UI and styling. - Integrated a progress ring component to visualize the quality of plans. - Applied a new font style across the application for consistency.
235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
// Reemplaza la sección del sparkline por estas tarjetas y ajusta el loader.
|
|
// + Añade un ProgressRing (SVG) para el % de calidad.
|
|
|
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
import * as Icons from 'lucide-react'
|
|
import { supabase } from '@/auth/supabase'
|
|
import { useMemo } from 'react'
|
|
|
|
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
|
|
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 RecentItem = { id: string; tipo: 'plan' | 'asignatura'; nombre: string | null; fecha: string | null }
|
|
|
|
type LoaderData = {
|
|
facultad: Facultad
|
|
counts: { carreras: number; planes: number; asignaturas: number; criterios: number }
|
|
recientes: RecentItem[]
|
|
calidadPlanesPct: number
|
|
saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number }
|
|
}
|
|
|
|
export const Route = createFileRoute('/_authenticated/facultad/$facultadId')({
|
|
component: RouteComponent,
|
|
// puedes mantener tu DashboardSkeleton actual como pendingComponent si ya lo tienes
|
|
loader: async ({ params }): Promise<LoaderData> => {
|
|
const facultadId = params.facultadId
|
|
|
|
// Facultad
|
|
const { data: facultad, error: facErr } = await supabase
|
|
.from('facultades').select('id, nombre, icon, color').eq('id', facultadId).single()
|
|
if (facErr || !facultad) throw facErr ?? new Error('Facultad no encontrada')
|
|
|
|
// Carreras
|
|
const { data: carreras, error: carErr } = await supabase
|
|
.from('carreras').select('id, nombre').eq('facultad_id', facultadId)
|
|
if (carErr) throw carErr
|
|
const carreraIds = (carreras ?? []).map(c => c.id)
|
|
|
|
// Planes
|
|
let planes: Plan[] = []
|
|
if (carreraIds.length) {
|
|
const { data, error } = await supabase
|
|
.from('plan_estudios')
|
|
.select('id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos')
|
|
.in('carrera_id', carreraIds)
|
|
if (error) throw error
|
|
planes = (data ?? []) as Plan[]
|
|
}
|
|
|
|
// Asignaturas
|
|
let asignaturas: Asignatura[] = []
|
|
const planIds = planes.map(p => p.id)
|
|
if (planIds.length) {
|
|
const { data, error } = await supabase
|
|
.from('asignaturas')
|
|
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
|
|
.in('plan_id', planIds)
|
|
if (error) throw error
|
|
asignaturas = (data ?? []) as Asignatura[]
|
|
}
|
|
|
|
// Criterios por carrera_id (tu cambio)
|
|
let criterios = 0
|
|
if (carreraIds.length) {
|
|
const { count, error } = await supabase
|
|
.from('criterios_carrera')
|
|
.select('*', { count: 'exact', head: true })
|
|
.in('carrera_id', carreraIds)
|
|
if (error) throw error
|
|
criterios = count ?? 0
|
|
}
|
|
|
|
// ====== KPIs de calidad ======
|
|
// Plan “completo” si tiene estos campos no vacíos:
|
|
const planKeys: (keyof Plan)[] = [
|
|
'objetivo_general', 'perfil_ingreso', 'perfil_egreso', 'sistema_evaluacion', 'total_creditos',
|
|
]
|
|
const completos = planes.filter(p =>
|
|
planKeys.every(k => p[k] !== null && p[k] !== '' && p[k] !== 0)
|
|
).length
|
|
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
|
|
|
// Salud de asignaturas: faltantes
|
|
const sinBibliografia = asignaturas.filter(a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)).length
|
|
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion || a.criterios_evaluacion.trim() === '').length
|
|
const sinContenidos = asignaturas.filter(a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)).length
|
|
|
|
// Actividad reciente (planes + asignaturas)
|
|
const recientes: RecentItem[] = [
|
|
...planes.map(p => ({ id: p.id, tipo: 'plan' as const, nombre: p.nombre, fecha: p.fecha_creacion })),
|
|
...asignaturas.map(a => ({ id: a.id, tipo: 'asignatura' as const, nombre: a.nombre, fecha: a.fecha_creacion })),
|
|
]
|
|
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
|
.slice(0, 6)
|
|
|
|
return {
|
|
facultad,
|
|
counts: {
|
|
carreras: carreras?.length ?? 0,
|
|
planes: planes.length,
|
|
asignaturas: asignaturas.length,
|
|
criterios,
|
|
},
|
|
recientes,
|
|
calidadPlanesPct,
|
|
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
|
}
|
|
},
|
|
})
|
|
|
|
function gradientFrom(color?: string | null) {
|
|
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
|
|
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
|
|
}
|
|
|
|
// ====== UI helpers ======
|
|
function ProgressRing({ pct }: { pct: number }) {
|
|
const r = 42, c = 2 * Math.PI * r
|
|
const offset = 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" 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="currentColor" strokeWidth="12"
|
|
strokeDasharray={c} strokeDashoffset={offset} 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 HealthItem({ 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 border ${warn ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-neutral-200 bg-white'}`}>
|
|
<span className="text-sm">{label}</span>
|
|
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
function RouteComponent() {
|
|
const { facultad, counts, recientes, calidadPlanesPct, saludAsignaturas } = Route.useLoaderData() as LoaderData
|
|
const HeaderIcon = (Icons as any)[facultad.icon] || Icons.Building
|
|
const headerBg = useMemo(() => ({ background: gradientFrom(facultad.color) }), [facultad.color])
|
|
|
|
return (
|
|
<div className="p-6 space-y-8">
|
|
{/* Header */}
|
|
<div className="relative rounded-3xl overflow-hidden text-white shadow-xl" style={headerBg}>
|
|
<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">
|
|
<HeaderIcon className="w-16 h-16 md:w-20 md:h-20 drop-shadow" />
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-bold">{facultad.nombre}</h1>
|
|
<p className="opacity-90">Calidad y estado académico de la facultad</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Métricas principales */}
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Metric to={`/_authenticated/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
|
|
<Metric to={`/_authenticated/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
|
|
<Metric to={`/_authenticated/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
|
|
<Metric to={`/_authenticated/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
|
|
</div>
|
|
|
|
{/* Calidad + Salud */}
|
|
<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="font-semibold mb-3">Calidad de planes</div>
|
|
<ProgressRing pct={calidadPlanesPct} />
|
|
<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>.
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
|
|
<div className="font-semibold mb-3">Salud de asignaturas</div>
|
|
<div className="space-y-2">
|
|
<HealthItem label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinBibliografia`} />
|
|
<HealthItem label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinCriterios`} />
|
|
<HealthItem label="Sin contenidos" value={saludAsignaturas.sinContenidos} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinContenidos`} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actividad reciente */}
|
|
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
|
|
<div className="font-semibold mb-3">Actividad reciente</div>
|
|
<ul className="space-y-2">
|
|
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
|
|
{recientes.map((r) => (
|
|
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
|
|
<Link to={`/ _authenticated/${r.tipo === 'plan' ? 'planes' : 'asignaturas'}/${r.id}`} className="truncate hover:underline">
|
|
<span className="inline-flex items-center gap-2">
|
|
{r.tipo === 'plan' ? <Icons.ScrollText className="w-4 h-4" /> : <Icons.BookOpen className="w-4 h-4" />}
|
|
{r.nombre ?? '—'}
|
|
</span>
|
|
</Link>
|
|
<span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Tarjeta métrica (igual a tu StatTile)
|
|
function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) {
|
|
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">
|
|
<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 group-hover:bg-neutral-200">
|
|
<Icon className="w-7 h-7" />
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|