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/facultad/$facultadId.tsx
T
alexrg 51faa98022 feat: Implement faculty management routes and UI components
- 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.
2025-08-20 19:09:31 -06:00

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>
)
}