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:
2025-08-22 14:32:43 -06:00
parent 9727f4c691
commit ca3fed69b2
16 changed files with 2274 additions and 118 deletions

View File

@@ -0,0 +1,447 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
/* ================== Tipos ================== */
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
type CarMini = { id: string; nombre: string; facultad: FacMini | null }
type PlanMini = { id: string; nombre: string; carrera: CarMini | null }
type Asignatura = {
id: string
nombre: string
clave: string | null
tipo: string | null
semestre: number | null
creditos: number | null
horas_teoricas: number | null
horas_practicas: number | null
objetivos: string | null
contenidos: Record<string, Record<string, string>> | null
bibliografia: string[] | null
criterios_evaluacion: string | null
fecha_creacion: string | null
plan: PlanMini | null
}
type LoaderData = {
asignaturas: Asignatura[]
}
/* ================== Ruta ================== */
export const Route = createFileRoute('/_authenticated/asignaturas')({
component: RouteComponent,
pendingComponent: PageSkeleton,
// Podemos filtrar por planId/carreraId/facultadId desde la URL si se envían
validateSearch: (search: Record<string, unknown>) => {
return {
q: (search.q as string) ?? '',
planId: (search.planId as string) ?? '',
carreraId: (search.carreraId as string) ?? '',
facultadId: (search.facultadId as string) ?? '',
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
}
},
loader: async (ctx): Promise<LoaderData> => {
// TanStack: el search vive en ctx.location.search
const search = (ctx.location?.search ?? {}) as {
q?: string
planId?: string
carreraId?: string
facultadId?: string
f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
}
const { planId, carreraId, facultadId } = search
// Resolver alcance por IDs opcionales (para filtrar antes de traer asignaturas)
let planIds: string[] | null = null
if (planId) {
planIds = [planId]
} else if (carreraId) {
const { data: planesCar, error } = await supabase
.from('plan_estudios')
.select('id')
.eq('carrera_id', carreraId)
if (error) throw error
planIds = (planesCar ?? []).map(p => p.id)
} else if (facultadId) {
const { data: carreras, error: carErr } = await supabase
.from('carreras')
.select('id')
.eq('facultad_id', facultadId)
if (carErr) throw carErr
const cIds = (carreras ?? []).map(c => c.id)
if (!cIds.length) {
// No hay carreras en la facultad ⇒ no hay asignaturas
return { asignaturas: [] }
}
const { data: planesFac, error: plaErr } = await supabase
.from('plan_estudios')
.select('id')
.in('carrera_id', cIds)
if (plaErr) throw plaErr
planIds = (planesFac ?? []).map(p => p.id)
}
// Si sabemos que no habrá resultados, evitamos pegarle a Supabase
if (planIds && planIds.length === 0) {
return { asignaturas: [] }
}
// Traer asignaturas + contexto de plan/carrera/facultad
let query = supabase
.from('asignaturas')
.select(`
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
plan:plan_estudios (
id, nombre,
carrera:carreras (
id, nombre,
facultad:facultades ( id, nombre, color, icon )
)
)
`)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
if (planIds) {
query = query.in('plan_id', planIds)
}
const { data, error: aErr } = await query
if (aErr) throw aErr
return { asignaturas: (data ?? []) as unknown as Asignatura[] }
},
})
/* ================== Página ================== */
function RouteComponent() {
const { asignaturas } = Route.useLoaderData() as LoaderData
const router = useRouter()
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' }
// Estado de filtros locales (arrancan con la URL)
const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
// Valores de selects
const semestres = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(String(a.semestre ?? '—')))
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud (contadores)
const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
for (const a of asignaturas) {
if (!a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)) sinBibliografia++
if (!a.criterios_evaluacion || !a.criterios_evaluacion.trim()) sinCriterios++
if (!a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)) sinContenidos++
}
return { sinBibliografia, sinCriterios, sinContenidos }
}, [asignaturas])
// Filtrado
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
return asignaturas.filter(a => {
const matchesQ =
!t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const flagOK =
!flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK
})
}, [q, sem, tipo, flag, asignaturas])
// Agrupación
const groups = useMemo(() => {
if (groupBy === 'ninguno') return [['Todas', filtered] as [string, Asignatura[]]]
const m = new Map<number | string, Asignatura[]>()
for (const a of filtered) {
const k = a.semestre ?? '—'
if (!m.has(k)) m.set(k, [])
m.get(k)!.push(a)
}
return Array.from(m.entries()).sort(([a], [b]) => {
if (a === '—') return 1
if (b === '—') return -1
return Number(a) - Number(b)
})
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
return (
<div className="p-6 space-y-6">
{/* HEADER */}
<div className="rounded-3xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm">
<div className="p-5 flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-bold flex items-center gap-2">
<Icons.BookOpen className="w-5 h-5" />
Asignaturas
</h1>
<div className="flex items-center gap-2">
{/* Crear nueva — puedes cambiar el destino si ya tienes ruta específica */}
<Link
to="/planes"
className="hidden sm:inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
search={{ crear: 'asignatura' }}
>
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
</Link>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filtros */}
<div className="grid gap-2 sm:grid-cols-[1fr,140px,180px,150px]">
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre, clave, plan, carrera, facultad…"
className="w-full"
/>
<Select value={sem} onValueChange={setSem}>
<SelectTrigger><SelectValue placeholder="Semestre" /></SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
</SelectContent>
</Select>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger><SelectValue placeholder="Tipo" /></SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem value="todos">Todos</SelectItem>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue placeholder="Agrupar por" /></SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Agrupar por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
</SelectContent>
</Select>
</div>
{/* Chips de salud (toggle) */}
<div className="flex flex-wrap items-center gap-2">
<HealthChip
active={flag === 'sinBibliografia'}
onClick={() => setFlag(flag === 'sinBibliografia' ? '' : 'sinBibliografia')}
icon={<Icons.BookMarked className="w-3.5 h-3.5" />}
label="Sin bibliografía"
value={salud.sinBibliografia}
/>
<HealthChip
active={flag === 'sinCriterios'}
onClick={() => setFlag(flag === 'sinCriterios' ? '' : 'sinCriterios')}
icon={<Icons.ClipboardX className="w-3.5 h-3.5" />}
label="Sin criterios de evaluación"
value={salud.sinCriterios}
/>
<HealthChip
active={flag === 'sinContenidos'}
onClick={() => setFlag(flag === 'sinContenidos' ? '' : 'sinContenidos')}
icon={<Icons.ListX className="w-3.5 h-3.5" />}
label="Sin contenidos"
value={salud.sinContenidos}
/>
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
Limpiar filtros
</Button>
)}
</div>
</div>
</div>
{/* LISTA */}
<div className="space-y-6">
{!groups.length && <div className="text-sm text-neutral-500 text-center py-16">Sin asignaturas</div>}
{groups.map(([key, items]) => (
<section key={String(key)} className="space-y-2">
{groupBy !== 'ninguno' && (
<div className="sticky top-[64px] -mx-6 px-6 py-1 bg-background/90 backdrop-blur border-l-4 border-primary/30 text-xs font-semibold text-neutral-600 z-10">
Semestre {key}
</div>
)}
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
</ul>
</section>
))}
</div>
</div>
)
}
/* ================== Card ================== */
function tipoMeta(tipo?: string | null) {
const t = (tipo ?? '').toLowerCase()
if (t.includes('oblig')) return { label: 'Obligatoria', Icon: Icons.BadgeCheck, cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' }
if (t.includes('opt')) return { label: 'Optativa', Icon: Icons.Wand2, cls: 'bg-amber-50 text-amber-800 border-amber-200' }
if (t.includes('taller')) return { label: 'Taller', Icon: Icons.Hammer, cls: 'bg-indigo-50 text-indigo-700 border-indigo-200' }
if (t.includes('lab')) return { label: 'Laboratorio', Icon: Icons.FlaskConical, cls: 'bg-sky-50 text-sky-700 border-sky-200' }
return { label: tipo ?? 'Genérica', Icon: Icons.BookOpen, cls: 'bg-neutral-100 text-neutral-700 border-neutral-200' }
}
function Chip({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] ${className}`}>{children}</span>
}
function AsignaturaCard({ a }: { a: Asignatura }) {
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
<div className="p-3">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
<meta.Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
{/* Menú rápido (placeholder extensible) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="mt-[-2px]"><Icons.MoreVertical className="w-4 h-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem asChild className="gap-2">
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
<Icons.FolderOpen className="w-4 h-4" /> Abrir
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2">
<Link to="/plan/$planId" params={{ planId: a.plan?.id ?? '' }}>
<Icons.ScrollText className="w-4 h-4" /> Ver plan
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{a.clave && <Chip className="bg-white/70"><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
<Chip className={meta.cls}><meta.Icon className="h-3 w-3" /> {meta.label}</Chip>
{a.creditos != null && <Chip className="bg-white/70"><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
{(horasT + horasP) > 0 && <Chip className="bg-white/70"><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
<Chip className="bg-white/70"><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? '—'}</Chip>
</div>
{/* Contexto del plan/carrera/facultad */}
{a.plan && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-neutral-600">
<span className="inline-flex items-center gap-1">
<Icons.ScrollText className="w-3.5 h-3.5" /> {a.plan.nombre}
</span>
{a.plan.carrera && (
<span className="inline-flex items-center gap-1">
<Icons.GraduationCap className="w-3.5 h-3.5" /> {a.plan.carrera.nombre}
</span>
)}
{a.plan.carrera?.facultad && (
<span className="inline-flex items-center gap-1">
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
</span>
)}
</div>
)}
{/* Objetivo resumido + CTA */}
<div className="mt-2 flex items-center justify-between">
<p className="text-xs text-neutral-700 line-clamp-2">{a.objetivos ?? '—'}</p>
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: a.id }}
className="ml-3 inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
>
Ver <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</div>
</div>
</li>
)
}
/* ================== UI helpers ================== */
function HealthChip({
active, onClick, icon, label, value,
}: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) {
return (
<button
onClick={onClick}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-1.5 text-xs ring-1 transition-colors
${active
? 'bg-amber-50 text-amber-800 ring-amber-300'
: 'bg-white/70 text-neutral-700 ring-neutral-200 hover:bg-neutral-50'}`}
>
{icon} {label}
<span className={`ml-1 inline-flex h-5 min-w-[1.5rem] items-center justify-center rounded-full px-1 text-[11px]
${active ? 'bg-amber-100 text-amber-900' : 'bg-neutral-100 text-neutral-800'}`}>
{value}
</span>
</button>
)
}
/* ================== Skeleton ================== */
function Pulse({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
}
function PageSkeleton() {
return (
<div className="p-6 space-y-6">
<Pulse className="h-36" />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => <Pulse key={i} className="h-32" />)}
</div>
</div>
)
}