// 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 => { 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 (
{pct}%
Planes con información clave completa
) } function HealthItem({ label, value, to }: { label: string; value: number; to: string }) { const warn = value > 0 return ( {label} {value} ) } 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 (
{/* Header */}

{facultad.nombre}

Calidad y estado académico de la facultad

{/* Métricas principales */}
{/* Calidad + Salud */}
Calidad de planes
Considera objetivo general, perfiles, sistema de evaluación y créditos.
Salud de asignaturas
{/* Actividad reciente */}
Actividad reciente
    {recientes.length === 0 &&
  • Sin actividad
  • } {recientes.map((r) => (
  • {r.tipo === 'plan' ? : } {r.nombre ?? '—'} {r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}
  • ))}
) } // Tarjeta métrica (igual a tu StatTile) function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) { return (
{label}
{value}
) }