feat: implement dashboard with KPIs, recent activity, and health metrics
- Added dashboard route with loader fetching KPIs, recent plans and subjects, and health metrics. - Created visual components for displaying KPIs and recent activity. - Implemented gradient background and user greeting based on role. - Added input for global search and quick links for creating new plans and subjects. refactor: update facultad progress ring rendering - Fixed rendering of progress ring in facultad detail view. fix: remove unnecessary link to subjects in plan detail view - Removed link to view subjects from the plan detail page for cleaner UI. feat: add create plan dialog in planes route - Introduced a dialog for creating new plans with form validation and role-based field visibility. - Integrated Supabase for creating plans and handling user roles. feat: enhance user management with create user dialog - Added functionality to create new users with role and claims management. - Implemented password generation and input handling for user creation. fix: update login redirect to dashboard - Changed default redirect after login from /planes to /dashboard for better user experience.
This commit is contained in:
@@ -1,9 +1,402 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
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 }>
|
||||
}
|
||||
|
||||
/* ========= Loader ========= */
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
loader: async (): Promise<LoaderData> => {
|
||||
// KPI counts
|
||||
const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] =
|
||||
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 = (planesRaw ?? []) as Plan[]
|
||||
const asignaturas = (asignRaw ?? []) 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] ?? '').toString().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: facCount ?? 0,
|
||||
carreras: carCount ?? 0,
|
||||
planes: planes.length,
|
||||
asignaturas: asignaturas.length
|
||||
},
|
||||
calidadPlanesPct,
|
||||
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
||||
recientes
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/dashboard"!</div>
|
||||
/* ========= 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 { kpis, calidadPlanesPct, saludAsignaturas, recientes } = Route.useLoaderData() as LoaderData
|
||||
const auth = useSupabaseAuth()
|
||||
const router = useRouter()
|
||||
const primary = hex(auth.claims?.facultad_color, '#1d4ed8') // si guardan color de facultad en claims
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
router.navigate({ to: '/planes', search: { q } })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30 border-white/30"
|
||||
onClick={() => 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"
|
||||
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"
|
||||
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="/_authenticated/facultades" label="Facultades" value={kpis.facultades} Icon={Icons.Building2} />
|
||||
<Tile to="/_authenticated/carreras" label="Carreras" value={kpis.carreras} Icon={Icons.GraduationCap} />
|
||||
<Tile to="/_authenticated/planes" label="Planes de estudio" value={kpis.planes} Icon={Icons.ScrollText} />
|
||||
<Tile to="/_authenticated/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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user