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:
447
src/routes/_authenticated/asignaturas.tsx
Normal file
447
src/routes/_authenticated/asignaturas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user